From c7925556f41c70c26fbfe8b268b3fa3b61590680 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Fri, 2 Jan 2026 16:58:03 +0900 Subject: [PATCH 01/74] =?UTF-8?q?[SRLT-111]=20Fix:=20=EC=8A=A4=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=A7=95=20=EC=84=9C=EB=B2=84=20Swagger=20Cors=20?= =?UTF-8?q?=EC=98=A4=EB=A6=AC=EC=A7=84=EC=9D=84=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= 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 8cac261..7983bf3 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 8cac261b72556ebd4213d2d0590de18bf61e285d +Subproject commit 7983bf3050cb3c027d4cac2c342e24378fe6a2fe From 949d0399209aa46043609133371f781606fd286a Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Mon, 5 Jan 2026 22:41:35 +0900 Subject: [PATCH 02/74] =?UTF-8?q?[SRLT-124]=20Refactor:=20Application=20La?= =?UTF-8?q?yer=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EB=B0=8F?= =?UTF-8?q?=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=9D=B4=EB=A6=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aireport/report/SpringAiReportGrader.java | 153 ++++++++ .../report/agent/FullReportGradeAgent.java | 4 + .../report/agent/SectionGradeAgent.java | 28 ++ .../impl/SpringAiFullReportGradeAgent.java | 46 +++ .../agent/impl/SpringAiSectionGradeAgent.java | 110 ++++++ .../SectionGradingCircuitBreaker.java | 115 ++++++ .../report/config/SectionAdvisorConfig.java | 45 +++ .../report/dto/SectionGradingResult.java | 27 ++ .../report/provider/ReportPromptProvider.java | 133 +++++++ .../provider/SpringAiAdvisorProvider.java | 39 +++ .../report/supervisor/ReportSupervisor.java | 106 ++++++ .../report/util/AiReportResponseParser.java | 330 ++++++++++++++++++ .../checklist/SpringAiChecklistGrader.java | 45 +++ .../agent/SpringAiChecklistAgent.java | 59 ++++ .../provider/ChecklistPromptProvider.java | 65 ++++ .../persistence/ExpertApplicationJpa.java | 78 +++++ ...tServiceImpl.java => AiReportService.java} | 48 +-- .../aireport/provided/AiReportUseCase.java | 12 + .../aireport/provided/dto/AiReportResult.java | 67 ++++ .../required/AiReportCommandPort.java | 4 + .../aireport/required/AiReportQueryPort.java | 11 + .../aireport/required/OcrProviderPort.java | 10 + .../required/PresignedUrlProviderPort.java | 10 + .../aireport/required/ReportGraderPort.java | 10 + .../businessplan/BusinessPlanService.java | 256 ++++++++++++++ .../provided/BusinessPlanUseCase.java | 38 ++ .../provided/dto/BusinessPlanResult.java | 93 +++++ .../provided/dto/SubSectionResult.java | 39 +++ .../required/BusinessPlanCommandPort.java | 4 + .../required/BusinessPlanQueryPort.java | 18 + .../required/ChecklistGraderPort.java | 20 ++ .../required/SpellCheckerPort.java | 12 + ...eUseCase.java => ExpertReportUseCase.java} | 0 ...erviceImpl.java => CredentialService.java} | 0 ...erQueryService.java => MemberService.java} | 0 ...ialService.java => CredentialUseCase.java} | 0 ...erQueryUseCase.java => MemberUseCase.java} | 0 37 files changed, 2013 insertions(+), 22 deletions(-) create mode 100644 src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java create mode 100644 src/main/java/starlight/adapter/aireport/report/agent/FullReportGradeAgent.java create mode 100644 src/main/java/starlight/adapter/aireport/report/agent/SectionGradeAgent.java create mode 100644 src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiFullReportGradeAgent.java create mode 100644 src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiSectionGradeAgent.java create mode 100644 src/main/java/starlight/adapter/aireport/report/circuitbreaker/SectionGradingCircuitBreaker.java create mode 100644 src/main/java/starlight/adapter/aireport/report/config/SectionAdvisorConfig.java create mode 100644 src/main/java/starlight/adapter/aireport/report/dto/SectionGradingResult.java create mode 100644 src/main/java/starlight/adapter/aireport/report/provider/ReportPromptProvider.java create mode 100644 src/main/java/starlight/adapter/aireport/report/provider/SpringAiAdvisorProvider.java create mode 100644 src/main/java/starlight/adapter/aireport/report/supervisor/ReportSupervisor.java create mode 100644 src/main/java/starlight/adapter/aireport/report/util/AiReportResponseParser.java create mode 100644 src/main/java/starlight/adapter/businessplan/checklist/SpringAiChecklistGrader.java create mode 100644 src/main/java/starlight/adapter/businessplan/checklist/agent/SpringAiChecklistAgent.java create mode 100644 src/main/java/starlight/adapter/businessplan/checklist/provider/ChecklistPromptProvider.java create mode 100644 src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java rename src/main/java/starlight/application/aireport/{AiReportServiceImpl.java => AiReportService.java} (70%) create mode 100644 src/main/java/starlight/application/aireport/provided/AiReportUseCase.java create mode 100644 src/main/java/starlight/application/aireport/provided/dto/AiReportResult.java create mode 100644 src/main/java/starlight/application/aireport/required/AiReportCommandPort.java create mode 100644 src/main/java/starlight/application/aireport/required/AiReportQueryPort.java create mode 100644 src/main/java/starlight/application/aireport/required/OcrProviderPort.java create mode 100644 src/main/java/starlight/application/aireport/required/PresignedUrlProviderPort.java create mode 100644 src/main/java/starlight/application/aireport/required/ReportGraderPort.java create mode 100644 src/main/java/starlight/application/businessplan/BusinessPlanService.java create mode 100644 src/main/java/starlight/application/businessplan/provided/BusinessPlanUseCase.java create mode 100644 src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResult.java create mode 100644 src/main/java/starlight/application/businessplan/provided/dto/SubSectionResult.java create mode 100644 src/main/java/starlight/application/businessplan/required/BusinessPlanCommandPort.java create mode 100644 src/main/java/starlight/application/businessplan/required/BusinessPlanQueryPort.java create mode 100644 src/main/java/starlight/application/businessplan/required/ChecklistGraderPort.java create mode 100644 src/main/java/starlight/application/businessplan/required/SpellCheckerPort.java rename src/main/java/starlight/application/expertReport/provided/{ExpertReportServiceUseCase.java => ExpertReportUseCase.java} (100%) rename src/main/java/starlight/application/member/{CredentialServiceImpl.java => CredentialService.java} (100%) rename src/main/java/starlight/application/member/{MemberQueryService.java => MemberService.java} (100%) rename src/main/java/starlight/application/member/provided/{CredentialService.java => CredentialUseCase.java} (100%) rename src/main/java/starlight/application/member/provided/{MemberQueryUseCase.java => MemberUseCase.java} (100%) diff --git a/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java b/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java new file mode 100644 index 0000000..2765417 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java @@ -0,0 +1,153 @@ +package starlight.adapter.aireport.reportgrader; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; +import starlight.adapter.aireport.reportgrader.agent.SectionGradeAgent; +import starlight.adapter.aireport.reportgrader.dto.SectionGradingResult; +import starlight.adapter.aireport.reportgrader.supervisor.ReportSupervisor; +import starlight.application.aireport.provided.dto.AiReportResult; +import starlight.application.aireport.required.ReportGraderPort; +import starlight.application.businessplan.util.BusinessPlanContentExtractor; +import starlight.shared.enumerate.SectionType; + +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executor; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * AI 리포트 채점을 오케스트레이션하는 컴포넌트 + * 4개의 섹션별 Advisor를 병렬로 실행하고, 슈퍼바이저가 장단점을 생성 + */ +@Slf4j +@Component +public class SpringAiReportGrader implements ReportGraderPort { + + private final Map advisors; + private final ReportSupervisor supervisor; + private final BusinessPlanContentExtractor contentExtractor; + private final Executor sectionGradingExecutor; + + public SpringAiReportGrader( + List advisorList, + ReportSupervisor supervisor, + BusinessPlanContentExtractor contentExtractor, + @Qualifier("sectionGradingExecutor") Executor sectionGradingExecutor + ) { + this.advisors = advisorList.stream() + .collect(Collectors.toMap( + SectionGradeAgent::getSectionType, + advisor -> advisor + )); + this.supervisor = supervisor; + this.contentExtractor = contentExtractor; + this.sectionGradingExecutor = sectionGradingExecutor; + } + + @Override + public AiReportResult gradeWithSectionAgents(String content) { + // 섹션별 내용 추출 (전체 텍스트에서) + Map sectionContents = contentExtractor.extractSectionContentsFromText(content); + + // 4개 섹션을 병렬로 채점 + List> futures = Arrays.asList( + SectionType.PROBLEM_RECOGNITION, + SectionType.FEASIBILITY, + SectionType.GROWTH_STRATEGY, + SectionType.TEAM_COMPETENCE + ).stream() + .map(sectionType -> { + SectionGradeAgent advisor = advisors.get(sectionType); + String sectionContent = sectionContents.get(sectionType); + + if (advisor != null && sectionContent != null && !sectionContent.isBlank()) { + return CompletableFuture + .supplyAsync( + () -> advisor.gradeSection(sectionContent), + sectionGradingExecutor + ) + .exceptionally(ex -> { + log.error("[{}] 채점 중 예외 발생", sectionType, ex); + return SectionGradingResult.failure(sectionType, ex.getMessage()); + }); + } else { + // 섹션 내용이 없으면 기본값 반환 + return CompletableFuture.completedFuture( + SectionGradingResult.failure(sectionType, "섹션 내용 없음") + ); + } + }) + .collect(Collectors.toList()); + + // 모든 채점 완료 대기 (최대 2분) + List results = futures.stream() + .map(future -> { + try { + return future.get(2, TimeUnit.MINUTES); + } catch (Exception e) { + log.error("섹션 채점 Future 완료 실패", e); + return SectionGradingResult.failure( + SectionType.PROBLEM_RECOGNITION, + "타임아웃 또는 예외" + ); + } + }) + .collect(Collectors.toList()); + + log.info("모든 섹션 채점 완료. 성공: {}, 실패: {}", + results.stream().filter(SectionGradingResult::success).count(), + results.stream().filter(r -> !r.success()).count() + ); + + // 슈퍼바이저가 장단점 생성 + List strengths = + supervisor.generateStrengths(content, results); + List weaknesses = + supervisor.generateWeaknesses(content, results); + + // 결과 통합 + return assembleReportResponse(results, strengths, weaknesses); + } + + private AiReportResult assembleReportResponse( + List results, + List strengths, + List weaknesses + ) { + Integer problemRecognitionScore = extractScore( + results, + SectionType.PROBLEM_RECOGNITION + ); + Integer feasibilityScore = extractScore(results, SectionType.FEASIBILITY); + Integer growthStrategyScore = extractScore(results, SectionType.GROWTH_STRATEGY); + Integer teamCompetenceScore = extractScore(results, SectionType.TEAM_COMPETENCE); + + List sectionScores = + results.stream() + .filter(SectionGradingResult::success) + .map(SectionGradingResult::sectionScore) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + // 기존 AiReportResponse.fromGradingResult 사용 (구조 동일) + return AiReportResult.fromGradingResult( + problemRecognitionScore, + feasibilityScore, + growthStrategyScore, + teamCompetenceScore, + sectionScores, + strengths, + weaknesses + ); + } + + private Integer extractScore(List results, SectionType type) { + return results.stream() + .filter(r -> r.sectionType() == type) + .findFirst() + .map(SectionGradingResult::score) + .orElse(0); + } +} diff --git a/src/main/java/starlight/adapter/aireport/report/agent/FullReportGradeAgent.java b/src/main/java/starlight/adapter/aireport/report/agent/FullReportGradeAgent.java new file mode 100644 index 0000000..9fbde99 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/agent/FullReportGradeAgent.java @@ -0,0 +1,4 @@ +package starlight.adapter.aireport.report.agent.impl; + +public interface FullReportGradeAgent { +} diff --git a/src/main/java/starlight/adapter/aireport/report/agent/SectionGradeAgent.java b/src/main/java/starlight/adapter/aireport/report/agent/SectionGradeAgent.java new file mode 100644 index 0000000..4b00638 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/agent/SectionGradeAgent.java @@ -0,0 +1,28 @@ +package starlight.adapter.aireport.reportgrader.agent; + +import starlight.adapter.aireport.reportgrader.dto.SectionGradingResult; +import starlight.shared.enumerate.SectionType; + +public interface SectionGradeAgent { + SectionType getSectionType(); + SectionGradingResult gradeSection(String sectionContent); + + /** + * SectionType의 tag를 기반으로 filter expression 생성 + * SubSectionType의 tag는 사용하지 않음 + */ + default String buildFilterExpression() { + SectionType sectionType = getSectionType(); + String tag = sectionType.getTag(); + + if (tag == null || tag.isBlank()) { + return null; + } + + // 단순히 "tag == 'problem_recognition'" 형식으로 반환 + return "tag == '" + tag + "'"; + } +} + + + 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 new file mode 100644 index 0000000..d3e2153 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiFullReportGradeAgent.java @@ -0,0 +1,46 @@ +package starlight.adapter.aireport.report.agent.impl; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; +import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.stereotype.Component; +import starlight.adapter.aireport.report.provider.SpringAiAdvisorProvider; +import starlight.adapter.aireport.report.provider.ReportPromptProvider; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SpringAiGenerator { + + private final ChatClient.Builder chatClientBuilder; + private final ReportPromptProvider reportPromptProvider; + private final SpringAiAdvisorProvider advisorProvider; + + /** + * 전체 프롬프트를 사용하여 LLM에 리포트 채점을 요청하고 응답을 반환 + * @param content PDF에서 추출한 텍스트 또는 전체 사업계획서 내용 + * @return LLM 응답 문자열 (JSON 형식) + */ + public String generateReport(String content) { + Prompt prompt = reportPromptProvider.createReportGradingPrompt(content); + + ChatClient chatClient = chatClientBuilder.build(); + QuestionAnswerAdvisor qaAdvisor = advisorProvider + .getQuestionAnswerAdvisor(0.6, 3, null); + SimpleLoggerAdvisor slAdvisor = advisorProvider.getSimpleLoggerAdvisor(); + + return chatClient + .prompt(prompt) + .options(ChatOptions.builder() + .temperature(0.0) + .topP(0.1) + .build()) + .advisors(qaAdvisor, slAdvisor) + .call() + .content(); + } +} 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 new file mode 100644 index 0000000..69b0dd2 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiSectionGradeAgent.java @@ -0,0 +1,110 @@ +package starlight.adapter.aireport.reportgrader.agent.impl; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; +import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import starlight.adapter.aireport.reportgrader.agent.SectionGradeAgent; +import starlight.adapter.aireport.reportgrader.circuitbreaker.SectionGradingCircuitBreaker; +import starlight.adapter.aireport.reportgrader.dto.SectionGradingResult; +import starlight.adapter.aireport.reportgrader.provider.SpringAiAdvisorProvider; +import starlight.adapter.aireport.reportgrader.provider.ReportPromptProvider; +import starlight.adapter.aireport.reportgrader.util.AiReportResponseParser; +import starlight.application.aireport.provided.dto.AiReportResult; +import starlight.shared.enumerate.SectionType; + +@Slf4j +@RequiredArgsConstructor +public class SpringAiSectionGradeAgent implements SectionGradeAgent { + + private final SectionType sectionType; + private final ChatClient.Builder chatClientBuilder; + private final ReportPromptProvider reportPromptProvider; + private final SpringAiAdvisorProvider advisorProvider; + private final AiReportResponseParser responseParser; + private final SectionGradingCircuitBreaker circuitBreaker; + + @Override + public SectionType getSectionType() { + return sectionType; + } + + @Override + public SectionGradingResult gradeSection(String sectionContent) { + // 서킷브레이커 체크 + if (!circuitBreaker.allowRequest(getSectionType())) { + log.warn("[{}] Circuit breaker is OPEN", getSectionType()); + 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()); + } + + 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()); + } + } + + private SectionGradingResult parseSectionResult(String llmResponse) { + try { + AiReportResult fullResponse = responseParser.parse(llmResponse); + + // SectionType의 score 추출 메서드 사용 + Integer score = getSectionType().extractScore(fullResponse); + + // sectionScores에서 해당 섹션 찾기 + String sectionTypeString = getSectionType().getSectionTypeString(); + AiReportResult.SectionScoreDetailResponse sectionScore = null; + if (sectionTypeString != null) { + sectionScore = fullResponse.sectionScores().stream() + .filter(ss -> sectionTypeString.equals(ss.sectionType())) + .findFirst() + .orElse(null); + } + + return SectionGradingResult.success(getSectionType(), score, sectionScore); + + } catch (Exception e) { + log.error("[{}] 응답 파싱 실패", getSectionType(), e); + return SectionGradingResult.failure(getSectionType(), "파싱 실패: " + e.getMessage()); + } + } +} diff --git a/src/main/java/starlight/adapter/aireport/report/circuitbreaker/SectionGradingCircuitBreaker.java b/src/main/java/starlight/adapter/aireport/report/circuitbreaker/SectionGradingCircuitBreaker.java new file mode 100644 index 0000000..5221a91 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/circuitbreaker/SectionGradingCircuitBreaker.java @@ -0,0 +1,115 @@ +package starlight.adapter.aireport.reportgrader.circuitbreaker; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import starlight.shared.enumerate.SectionType; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +@Slf4j +@Component +public class SectionGradingCircuitBreaker { + + private static final int FAILURE_THRESHOLD = 5; // 5번 연속 실패 시 오픈 + private static final int SUCCESS_THRESHOLD = 2; // 2번 연속 성공 시 클로즈 + private static final long HALF_OPEN_TIMEOUT_SECONDS = 60; // 60초 후 하프오픈 시도 + + private final Map circuitStates = new ConcurrentHashMap<>(); + + public enum State { + CLOSED, // 정상 동작 + OPEN, // 차단됨 + HALF_OPEN // 테스트 중 + } + + private static class CircuitState { + private final AtomicReference state = new AtomicReference<>(State.CLOSED); + private final AtomicInteger failureCount = new AtomicInteger(0); + private final AtomicInteger successCount = new AtomicInteger(0); + private final AtomicReference lastFailureTime = new AtomicReference<>(); + + public boolean allowRequest() { + State current = state.get(); + + if (current == State.CLOSED) { + return true; + } + + if (current == State.OPEN) { + // 타임아웃 체크하여 HALF_OPEN으로 전환 + LocalDateTime lastFailure = lastFailureTime.get(); + if (lastFailure != null && + java.time.Duration.between(lastFailure, LocalDateTime.now()) + .getSeconds() >= HALF_OPEN_TIMEOUT_SECONDS) { + if (state.compareAndSet(State.OPEN, State.HALF_OPEN)) { + successCount.set(0); + log.info("Circuit breaker transitioning to HALF_OPEN"); + return true; + } + } + return false; + } + + // HALF_OPEN 상태 + return true; + } + + public void recordSuccess() { + State current = state.get(); + if (current == State.HALF_OPEN) { + int success = successCount.incrementAndGet(); + if (success >= SUCCESS_THRESHOLD) { + if (state.compareAndSet(State.HALF_OPEN, State.CLOSED)) { + failureCount.set(0); + log.info("Circuit breaker CLOSED after successful recovery"); + } + } + } else if (current == State.CLOSED) { + failureCount.set(0); // 성공 시 실패 카운트 리셋 + } + } + + public void recordFailure() { + State current = state.get(); + if (current == State.CLOSED || current == State.HALF_OPEN) { + int failures = failureCount.incrementAndGet(); + lastFailureTime.set(LocalDateTime.now()); + + if (failures >= FAILURE_THRESHOLD) { + if (state.compareAndSet(current, State.OPEN)) { + log.warn("Circuit breaker OPENED after {} failures", failures); + } + } + } + } + } + + public boolean allowRequest(SectionType sectionType) { + CircuitState circuit = circuitStates.computeIfAbsent( + sectionType, + k -> new CircuitState() + ); + return circuit.allowRequest(); + } + + public void recordSuccess(SectionType sectionType) { + CircuitState circuit = circuitStates.get(sectionType); + if (circuit != null) { + circuit.recordSuccess(); + } + } + + public void recordFailure(SectionType sectionType) { + CircuitState circuit = circuitStates.get(sectionType); + if (circuit != null) { + circuit.recordFailure(); + } + } +} + + + diff --git a/src/main/java/starlight/adapter/aireport/report/config/SectionAdvisorConfig.java b/src/main/java/starlight/adapter/aireport/report/config/SectionAdvisorConfig.java new file mode 100644 index 0000000..3c80fb1 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/config/SectionAdvisorConfig.java @@ -0,0 +1,45 @@ +package starlight.adapter.aireport.reportgrader.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import starlight.adapter.aireport.reportgrader.agent.SectionGradeAgent; +import starlight.adapter.aireport.reportgrader.agent.impl.SpringAiSectionGradeAgent; +import starlight.adapter.aireport.reportgrader.circuitbreaker.SectionGradingCircuitBreaker; +import starlight.adapter.aireport.reportgrader.provider.SpringAiAdvisorProvider; +import starlight.adapter.aireport.reportgrader.provider.ReportPromptProvider; +import starlight.adapter.aireport.reportgrader.util.AiReportResponseParser; +import starlight.shared.enumerate.SectionType; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +@Configuration +@RequiredArgsConstructor +public class SectionAdvisorConfig { + + private final ChatClient.Builder chatClientBuilder; + private final ReportPromptProvider reportPromptProvider; + private final SpringAiAdvisorProvider advisorProvider; + private final AiReportResponseParser responseParser; + private final SectionGradingCircuitBreaker circuitBreaker; + + @Bean + public List sectionAdvisors() { + // 채점 대상이 아닌 OVERVIEW를 제외한 모든 SectionType에 대해 Advisor 생성 + return Arrays.stream(SectionType.values()) + .filter(sectionType -> sectionType.getTag() != null) // OVERVIEW 제외 + .map(sectionType -> new SpringAiSectionGradeAgent( + sectionType, + chatClientBuilder, + reportPromptProvider, + advisorProvider, + responseParser, + circuitBreaker + )) + .collect(Collectors.toList()); + } +} + diff --git a/src/main/java/starlight/adapter/aireport/report/dto/SectionGradingResult.java b/src/main/java/starlight/adapter/aireport/report/dto/SectionGradingResult.java new file mode 100644 index 0000000..3ee3b3d --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/dto/SectionGradingResult.java @@ -0,0 +1,27 @@ +package starlight.adapter.aireport.reportgrader.dto; + +import starlight.application.aireport.provided.dto.AiReportResult.SectionScoreDetailResponse; +import starlight.shared.enumerate.SectionType; + +public record SectionGradingResult( + SectionType sectionType, + Integer score, + SectionScoreDetailResponse sectionScore, + boolean success, + String errorMessage +) { + public static SectionGradingResult success( + SectionType sectionType, + Integer score, + SectionScoreDetailResponse sectionScore + ) { + return new SectionGradingResult(sectionType, score, sectionScore, true, null); + } + + public static SectionGradingResult failure(SectionType sectionType, String errorMessage) { + return new SectionGradingResult(sectionType, 0, null, false, errorMessage); + } +} + + + diff --git a/src/main/java/starlight/adapter/aireport/report/provider/ReportPromptProvider.java b/src/main/java/starlight/adapter/aireport/report/provider/ReportPromptProvider.java new file mode 100644 index 0000000..24fd839 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/provider/ReportPromptProvider.java @@ -0,0 +1,133 @@ +package starlight.adapter.aireport.reportgrader.provider; + +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import starlight.domain.businessplan.enumerate.SubSectionType; +import starlight.shared.enumerate.SectionType; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Component +public class ReportPromptProvider { + + // 사업계획서 전체 채점 프롬프트 + @Value("${prompt.report.grading.system}") + private String reportGradingSystemPrompt; + + // 사업계획서 섹션별 채점 공통 프롬프트 + @Value("${prompt.report.section.default.system}") + private String sectionDefaultSystemPrompt; + + // 사업계획서 섹션별 채점 프롬프트 + @Value("${prompt.report.section.problem_recognition.system}") + private String problemRecognitionSystemPrompt; + + @Value("${prompt.report.section.feasibility.system}") + private String feasibilitySystemPrompt; + + @Value("${prompt.report.section.growth_strategy.system}") + private String growthStrategySystemPrompt; + + @Value("${prompt.report.section.team_competence.system}") + private String teamCompetenceSystemPrompt; + + @Value("${prompt.checklist.grading.system}") + private String checklistGradingSystemPrompt; + + @Value("${prompt.checklist.grading.user.template}") + private String checklistGradingUserPromptTemplate; + + /** + * 리포트 채점용 Prompt 객체 생성 + * @deprecated 전체 리포트를 한 번에 채점하는 방식은 더 이상 권장되지 않습니다. + * 대신 {@link #createSectionGradingPrompt(SectionType, String)}를 사용하여 섹션별로 채점하는 방식을 사용하세요. + */ + @Deprecated + public Prompt createReportGradingPrompt(String businessPlanContent) { + Message systemMessage = new SystemMessage(getReportGradingSystemPrompt()); + Message userMessage = new UserMessage(businessPlanContent); // 사업계획서 내용만 직접 전달 + return new Prompt(List.of(systemMessage, userMessage)); + } + + /** + * 섹션별 채점용 Prompt 객체 생성 + */ + public Prompt createSectionGradingPrompt(SectionType sectionType, String sectionContent) { + String systemPrompt = getSectionGradingSystemPrompt(sectionType); + + Message systemMessage = new SystemMessage(systemPrompt); + Message userMessage = new UserMessage(sectionContent); // 섹션 내용만 직접 전달 + return new Prompt(List.of(systemMessage, userMessage)); + } + + /** + * 체크리스트 채점용 Prompt 객체 생성 + */ + public Prompt createChecklistGradingPrompt( + SubSectionType subSectionType, + String content, + List criteria, + List detailedCriteria) { + String userPrompt = buildChecklistGradingUserPrompt(subSectionType, content, criteria, detailedCriteria); + Message systemMessage = new SystemMessage(checklistGradingSystemPrompt); + Message userMessage = new UserMessage(userPrompt); + return new Prompt(List.of(systemMessage, userMessage)); + } + + /** + * 리포트 채점용 시스템 프롬프트 + */ + private String getReportGradingSystemPrompt() { + return reportGradingSystemPrompt; + } + + /** + * 섹션별 채점용 시스템 프롬프트 + * 공통 프롬프트와 섹션별 프롬프트를 합쳐서 반환 + */ + private String getSectionGradingSystemPrompt(SectionType sectionType) { + String sectionSpecificPrompt = switch (sectionType) { + case PROBLEM_RECOGNITION -> problemRecognitionSystemPrompt; + case FEASIBILITY -> feasibilitySystemPrompt; + case GROWTH_STRATEGY -> growthStrategySystemPrompt; + case TEAM_COMPETENCE -> teamCompetenceSystemPrompt; + default -> ""; // 기본값 + }; + + // 공통 프롬프트와 섹션별 프롬프트를 합침 + return sectionDefaultSystemPrompt + "\n\n" + sectionSpecificPrompt; + } + + /** + * 체크리스트 채점용 사용자 프롬프트 생성 + */ + private String buildChecklistGradingUserPrompt( + SubSectionType subSectionType, + String content, + List criteria, + List detailedCriteria) { + // 체크리스트 상세 기준 포맷팅 + StringBuilder criteriaBuilder = new StringBuilder(); + for (int i = 0; i < criteria.size() && i < detailedCriteria.size(); i++) { + criteriaBuilder.append(i + 1).append(") ").append(criteria.get(i)).append("\n"); + criteriaBuilder.append(detailedCriteria.get(i)).append("\n\n"); + } + String formattedCriteria = criteriaBuilder.toString().trim(); + + Map variables = new HashMap<>(); + variables.put("subsectionType", subSectionType.getDescription()); + variables.put("checklistCriteria", formattedCriteria); + variables.put("input", content); + variables.put("requestLength", criteria.size()); + + PromptTemplate promptTemplate = new PromptTemplate(checklistGradingUserPromptTemplate); + return promptTemplate.render(variables); + } +} diff --git a/src/main/java/starlight/adapter/aireport/report/provider/SpringAiAdvisorProvider.java b/src/main/java/starlight/adapter/aireport/report/provider/SpringAiAdvisorProvider.java new file mode 100644 index 0000000..ccd9e20 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/provider/SpringAiAdvisorProvider.java @@ -0,0 +1,39 @@ +package starlight.adapter.aireport.reportgrader.provider; + +import lombok.RequiredArgsConstructor; +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.vectorstore.SearchRequest; +import org.springframework.ai.vectorstore.VectorStore; +import org.springframework.core.Ordered; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +@RequiredArgsConstructor +public class SpringAiAdvisorProvider { + + private final VectorStore vectorStore; + + public QuestionAnswerAdvisor getQuestionAnswerAdvisor(double similarityThreshold, int topK, String filter){ + SearchRequest.Builder builder = SearchRequest.builder() + .similarityThreshold(similarityThreshold) + .topK(topK); + + if (filter != null && !filter.trim().isEmpty()) { + builder.filterExpression(filter); + } + + SearchRequest searchRequest = builder.build(); + + return QuestionAnswerAdvisor + .builder(vectorStore) + .searchRequest(searchRequest) + .build(); + } + + public SimpleLoggerAdvisor getSimpleLoggerAdvisor(){ + return new SimpleLoggerAdvisor(Ordered.LOWEST_PRECEDENCE-1); + } +} diff --git a/src/main/java/starlight/adapter/aireport/report/supervisor/ReportSupervisor.java b/src/main/java/starlight/adapter/aireport/report/supervisor/ReportSupervisor.java new file mode 100644 index 0000000..4afd8aa --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/supervisor/ReportSupervisor.java @@ -0,0 +1,106 @@ +package starlight.adapter.aireport.reportgrader.supervisor; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import starlight.adapter.aireport.reportgrader.dto.SectionGradingResult; +import starlight.adapter.aireport.reportgrader.util.AiReportResponseParser; +import starlight.application.aireport.provided.dto.AiReportResult; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ReportSupervisor { + + private final ChatClient.Builder chatClientBuilder; + private final AiReportResponseParser responseParser; + + @Value("${prompt.report.supervisor.system}") + private String supervisorSystemPrompt; + + @Value("${prompt.report.supervisor.user.template}") + private String supervisorUserPromptTemplate; + + public List generateStrengths( + String businessPlanContent, + List sectionResults + ) { + return generateStrengthWeakness(businessPlanContent, sectionResults, "strengths"); + } + + public List generateWeaknesses( + String businessPlanContent, + List sectionResults + ) { + return generateStrengthWeakness(businessPlanContent, sectionResults, "weaknesses"); + } + + private List generateStrengthWeakness( + String businessPlanContent, + List sectionResults, + String type + ) { + try { + String prompt = buildSupervisorPrompt(businessPlanContent, sectionResults, type); + + ChatClient chatClient = chatClientBuilder.build(); + String llmResponse = chatClient + .prompt(new Prompt(List.of( + new SystemMessage(supervisorSystemPrompt), + new UserMessage(prompt) + ))) + .options(ChatOptions.builder() + .temperature(0.1) + .topP(0.2) + .build()) + .call() + .content(); + + return responseParser.parseStrengthWeakness(llmResponse, type); + + } catch (Exception e) { + log.error("Supervisor failed to generate {}", type, e); + return List.of(); // 빈 리스트 반환 + } + } + + private String buildSupervisorPrompt( + String businessPlanContent, + List sectionResults, + String type + ) { + // 섹션별 채점 결과 포맷팅 + StringBuilder sectionResultsBuilder = new StringBuilder(); + for (SectionGradingResult result : sectionResults) { + if (result.success()) { + sectionResultsBuilder.append(String.format("- %s: %d점\n", + result.sectionType().getDescription(), + result.score())); + } else { + sectionResultsBuilder.append(String.format("- %s: 채점 실패 (%s)\n", + result.sectionType().getDescription(), + result.errorMessage())); + } + } + + PromptTemplate promptTemplate = new PromptTemplate(supervisorUserPromptTemplate); + Map variables = new HashMap<>(); + variables.put("businessPlanContent", businessPlanContent); + variables.put("sectionResults", sectionResultsBuilder.toString().trim()); + variables.put("type", type); + + return promptTemplate.render(variables); + } +} + diff --git a/src/main/java/starlight/adapter/aireport/report/util/AiReportResponseParser.java b/src/main/java/starlight/adapter/aireport/report/util/AiReportResponseParser.java new file mode 100644 index 0000000..5ec3db8 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/util/AiReportResponseParser.java @@ -0,0 +1,330 @@ +package starlight.adapter.aireport.reportgrader.util; + +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; + +/** + * LLM 응답을 파싱하여 AiReportResponse로 변환하는 컴포넌트 + */ +@Slf4j +@Component +@RequiredArgsConstructor +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로 파싱 + * 파싱 실패 시 예외를 던집니다. + */ + public AiReportResult parse(String llmResponse) { + log.debug("Raw LLM response: {}", llmResponse); + + // 1. 기본 검증 + if (llmResponse == null || llmResponse.trim().isEmpty()) { + log.error("LLM response is null or empty"); + throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } + + try { + // 2. JSON 문자열 정리 + String cleanedJson = cleanJsonResponse(llmResponse); + log.debug("Cleaned JSON: {}", cleanedJson); + + // 3. JSON 파싱 시도 + JsonNode jsonNode = objectMapper.readTree(cleanedJson); + + // 4. 필수 필드 존재 여부 확인 + if (!jsonNode.has("problemRecognitionScore") || + !jsonNode.has("feasibilityScore") || + !jsonNode.has("growthStrategyScore") || + !jsonNode.has("teamCompetenceScore")) { + throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } + + // 5. 파싱 시도 + AiReportResult response = parseFromJsonNode(jsonNode); + + // 6. 파싱된 값이 기본값인지 확인 + if (isDefaultResponse(response)) { + log.error("Parsed response is default (all zeros), likely parsing failure"); + throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } + + return response; + } catch (Exception e) { + log.error("Failed to parse LLM response. Response: {}", llmResponse, e); + throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } + } + + /** + * JSON 응답 문자열 정리 및 복구 + */ + private String cleanJsonResponse(String json) { + if (json == null || json.trim().isEmpty()) { + return "{}"; + } + + String cleaned = json.trim(); + + // 1. JSON 코드 블록 마커 제거 (```json ... ``` 또는 ``` ... ```) + if (cleaned.startsWith("```json")) { + cleaned = cleaned.substring(7); + } else if (cleaned.startsWith("```")) { + cleaned = cleaned.substring(3); + } + if (cleaned.endsWith("```")) { + cleaned = cleaned.substring(0, cleaned.length() - 3); + } + cleaned = cleaned.trim(); + + // 2. "text" 필드에서 JSON 추출 (더 강력한 추출) + // 정규식으로 "text" 필드 추출 시도 + if (cleaned.contains("\"text\"") || cleaned.contains("'text'")) { + try { + // 먼저 JSON 파싱 시도 + JsonNode root = objectMapper.readTree(cleaned); + if (root.has("text") && root.get("text").isTextual()) { + cleaned = root.get("text").asText(); + } + } catch (Exception e) { + // JSON 파싱 실패 시 정규식으로 추출 시도 + try { + // "text" : "..." 패턴 찾기 + java.util.regex.Pattern pattern = java.util.regex.Pattern.compile( + "\"text\"\\s*:\\s*\"(.*)\"", + java.util.regex.Pattern.DOTALL + ); + java.util.regex.Matcher matcher = pattern.matcher(cleaned); + if (matcher.find()) { + String extracted = matcher.group(1); + // 이스케이프된 문자 처리 + extracted = extracted.replace("\\n", "\n") + .replace("\\\"", "\"") + .replace("\\\\", "\\"); + cleaned = extracted; + log.debug("Extracted text field using regex"); + } + } catch (Exception e2) { + log.warn("Failed to extract text field using regex: {}", e2.getMessage()); + } + } + } + + // 3. 잘못된 따옴표 패턴 수정 (공백이 포함된 필드명) + cleaned = cleaned.replaceAll("\"\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s+\"", "\"$1\""); + + return cleaned; + } + + /** + * JsonNode를 파싱하여 AiReportResponse로 변환 + */ + private AiReportResult parseFromJsonNode(JsonNode jsonNode) { + Integer problemRecognitionScore = jsonNode.path("problemRecognitionScore").asInt(0); + Integer feasibilityScore = jsonNode.path("feasibilityScore").asInt(0); + Integer growthStrategyScore = jsonNode.path("growthStrategyScore").asInt(0); + Integer teamCompetenceScore = jsonNode.path("teamCompetenceScore").asInt(0); + + // 강점 파싱 + 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(""); + String gradingListScores = sectionScoreNode.path("gradingListScores").asText("[]"); + + // gradingListScores가 유효한 JSON 문자열인지 검증 + if (!gradingListScores.equals("[]")) { + 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) { + log.warn("Failed to parse gradingListScores for sectionType: {}, using default. Value: {}", + sectionType, gradingListScores); + 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/businessplan/checklist/SpringAiChecklistGrader.java b/src/main/java/starlight/adapter/businessplan/checklist/SpringAiChecklistGrader.java new file mode 100644 index 0000000..a4e58c5 --- /dev/null +++ b/src/main/java/starlight/adapter/businessplan/checklist/SpringAiChecklistGrader.java @@ -0,0 +1,45 @@ +package starlight.adapter.businessplan.checklistgrader; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import starlight.adapter.businessplan.checklistgrader.generator.SpringAiChecklistGenerator; +import starlight.application.businessplan.required.ChecklistGraderPort; +import starlight.domain.businessplan.enumerate.SubSectionType; + +import java.util.List; +import java.util.ArrayList; +import starlight.adapter.aireport.reportgrader.provider.ChecklistPromptProvider; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SpringAiChecklistGrader implements ChecklistGraderPort { + + private final SpringAiChecklistGenerator generator; + private final ChecklistPromptProvider checklistCatalog; + + @Override + public List check( + SubSectionType subSectionType, + String content + ) { + // 1) 서브섹션별 체크리스트 기준 5개 확보 + List criteria = checklistCatalog.getCriteriaBySubSectionType(subSectionType); + List detailedCriteria = checklistCatalog.getDetailedCriteriaBySubSectionType(subSectionType); + + // 2) LLM 호출 → Boolean 배열 파싱 + List result = generator.generateChecklistArray(subSectionType, content, criteria, detailedCriteria); + + // 3) 보정: 항상 길이 5 보장 + return normalizeToFive(result); + } + + private List normalizeToFive(List in) { + List out = new ArrayList<>(5); + for (int i = 0; i < 5; i++) { + out.add(i < in.size() && in.get(i) != null ? in.get(i) : false); + } + return out; + } +} diff --git a/src/main/java/starlight/adapter/businessplan/checklist/agent/SpringAiChecklistAgent.java b/src/main/java/starlight/adapter/businessplan/checklist/agent/SpringAiChecklistAgent.java new file mode 100644 index 0000000..7a17837 --- /dev/null +++ b/src/main/java/starlight/adapter/businessplan/checklist/agent/SpringAiChecklistAgent.java @@ -0,0 +1,59 @@ +package starlight.adapter.businessplan.checklist.generator; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; +import org.springframework.ai.chat.prompt.ChatOptions; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.stereotype.Component; +import starlight.adapter.businessplan.checklist.provider.ChecklistPromptProvider; +import starlight.adapter.aireport.report.provider.SpringAiAdvisorProvider; +import starlight.domain.businessplan.enumerate.SubSectionType; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SpringAiChecklistGenerator { + + private final ChatClient.Builder chatClientBuilder; + private final ChecklistPromptProvider checklistPromptProvider; + private final SpringAiAdvisorProvider advisorProvider; + private final ObjectMapper objectMapper; + + public List generateChecklistArray( + SubSectionType subSectionType, + String content, + List criteria, + List detailedCriteria + ) { + Prompt prompt = checklistPromptProvider.createChecklistGradingPrompt( + subSectionType, content, criteria, detailedCriteria + ); + + ChatClient chatClient = chatClientBuilder.build(); + + SimpleLoggerAdvisor slAdvisor = advisorProvider.getSimpleLoggerAdvisor(); + + String output = chatClient + .prompt(prompt) + .options(ChatOptions.builder() + .temperature(0.1) + .topP(0.1) + .build()) + .advisors(slAdvisor) + .call() + .content(); + + try { + return objectMapper.readValue(output, new TypeReference>() { + }); + } catch (Exception e) { + return List.of(false, false, false, false, false); + } + } +} diff --git a/src/main/java/starlight/adapter/businessplan/checklist/provider/ChecklistPromptProvider.java b/src/main/java/starlight/adapter/businessplan/checklist/provider/ChecklistPromptProvider.java new file mode 100644 index 0000000..bb85cf2 --- /dev/null +++ b/src/main/java/starlight/adapter/businessplan/checklist/provider/ChecklistPromptProvider.java @@ -0,0 +1,65 @@ +package starlight.adapter.aireport.reportgrader.provider; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import starlight.domain.businessplan.enumerate.SubSectionType; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +@ConfigurationProperties(prefix = "prompt.checklist") +@Getter +@Setter +public class ChecklistPromptProvider { + + private Map catalog; + + @Getter + @Setter + public static class CatalogSection { + private List items; + } + + @Getter + @Setter + public static class ChecklistItem { + private String criteria; + private String detailed; + } + + // 서브섹션 타입에 해당하는 criteria 리스트를 반환합니다 + public List getCriteriaBySubSectionType(SubSectionType subSectionType) { + String tag = subSectionType.getTag(); + if (catalog == null || !catalog.containsKey(tag)) { + return List.of(); + } + CatalogSection section = catalog.get(tag); + if (section == null || section.getItems() == null) { + return List.of(); + } + return section.getItems().stream() + .map(ChecklistItem::getCriteria) + .filter(c -> c != null && !c.isEmpty()) + .collect(Collectors.toList()); + } + + // 서브섹션 타입에 해당하는 detailed-criteria 리스트를 반환합니다. + public List getDetailedCriteriaBySubSectionType(SubSectionType subSectionType) { + String tag = subSectionType.getTag(); + if (catalog == null || !catalog.containsKey(tag)) { + return List.of(); + } + CatalogSection section = catalog.get(tag); + if (section == null || section.getItems() == null) { + return List.of(); + } + return section.getItems().stream() + .map(ChecklistItem::getDetailed) + .filter(d -> d != null && !d.isEmpty()) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java new file mode 100644 index 0000000..b8a4ef3 --- /dev/null +++ b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java @@ -0,0 +1,78 @@ +package starlight.adapter.expertApplication.persistence; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import starlight.application.expertApplication.required.ExpertApplicationQueryPort; +import starlight.domain.expertApplication.entity.ExpertApplication; +import starlight.domain.expertApplication.exception.ExpertApplicationErrorType; +import starlight.domain.expertApplication.exception.ExpertApplicationException; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ExpertApplicationJpa implements ExpertApplicationQueryPort, + starlight.application.expert.required.ExpertApplicationCountLookupPort, + starlight.application.expertReport.required.ExpertApplicationCountLookupPort { + + private final ExpertApplicationRepository repository; + + @Override + public Boolean existsByExpertIdAndBusinessPlanId(Long expertId, Long businessPlanId) { + try { + return repository.existsByExpertIdAndBusinessPlanId(expertId, businessPlanId); + } catch (Exception e) { + log.error("전문가 신청 존재 여부 조회 중 오류가 발생했습니다.", e); + throw new ExpertApplicationException(ExpertApplicationErrorType.EXPERT_APPLICATION_QUERY_ERROR); + } + } + + @Override + public ExpertApplication save(ExpertApplication application) { + return repository.save(application); + } + + @Override + public Map countByExpertIds(List expertIds) { + try { + if (expertIds == null || expertIds.isEmpty()) { + return Collections.emptyMap(); + } + + return repository.countByExpertIds(expertIds).stream() + .collect(Collectors.toMap( + ExpertApplicationRepository.ExpertIdCountProjection::getExpertId, + p -> (long) p.getCount() + )); + } catch (Exception e) { + log.error("전문가별 신청 건수 조회 중 오류가 발생했습니다.", e); + throw new ExpertApplicationException(ExpertApplicationErrorType.EXPERT_APPLICATION_QUERY_ERROR); + } + } + + @Override + public Map countByExpertIdAndBusinessPlanIds(Long expertId, List businessPlanIds) { + try { + if (expertId == null) { + return Collections.emptyMap(); + } + if (businessPlanIds == null || businessPlanIds.isEmpty()) { + return Collections.emptyMap(); + } + + return repository.countByExpertIdAndBusinessPlanIds(expertId, businessPlanIds).stream() + .collect(Collectors.toMap( + ExpertApplicationRepository.BusinessPlanIdCountProjection::getBusinessPlanId, + p -> (long) p.getCount() + )); + } catch (Exception e) { + log.error("사업계획서별 신청 건수 조회 중 오류가 발생했습니다.", e); + throw new ExpertApplicationException(ExpertApplicationErrorType.EXPERT_APPLICATION_QUERY_ERROR); + } + } +} diff --git a/src/main/java/starlight/application/aireport/AiReportServiceImpl.java b/src/main/java/starlight/application/aireport/AiReportService.java similarity index 70% rename from src/main/java/starlight/application/aireport/AiReportServiceImpl.java rename to src/main/java/starlight/application/aireport/AiReportService.java index 6a7e123..bb2d7bc 100644 --- a/src/main/java/starlight/application/aireport/AiReportServiceImpl.java +++ b/src/main/java/starlight/application/aireport/AiReportService.java @@ -6,16 +6,17 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import starlight.adapter.ai.util.AiReportResponseParser; -import starlight.application.aireport.provided.AiReportService; -import starlight.application.aireport.provided.dto.AiReportResponse; -import starlight.application.aireport.required.AiReportGrader; -import starlight.application.aireport.required.AiReportQuery; -import starlight.application.businessplan.provided.BusinessPlanService; -import starlight.application.businessplan.provided.dto.BusinessPlanResponse; -import starlight.application.businessplan.required.BusinessPlanQuery; +import starlight.adapter.aireport.reportgrader.util.AiReportResponseParser; +import starlight.application.aireport.provided.AiReportUseCase; +import starlight.application.aireport.provided.dto.AiReportResult; +import starlight.application.aireport.required.AiReportGradingPort; +import starlight.application.aireport.required.AiReportQueryPort; +import starlight.application.businessplan.provided.BusinessPlanUseCase; +import starlight.application.businessplan.provided.dto.BusinessPlanResult; +import starlight.application.businessplan.required.BusinessPlanQueryPort; import starlight.application.businessplan.util.BusinessPlanContentExtractor; -import starlight.application.aireport.required.OcrProvider; +import starlight.application.aireport.required.OcrProviderPort; +import starlight.application.infrastructure.provided.LlmGenerator; import starlight.domain.aireport.entity.AiReport; import starlight.domain.aireport.exception.AiReportErrorType; import starlight.domain.aireport.exception.AiReportException; @@ -27,25 +28,26 @@ @Service @RequiredArgsConstructor @Transactional -public class AiReportServiceImpl implements AiReportService { +public class AiReportServiceImpl implements AiReportUseCase { - private final BusinessPlanQuery businessPlanQuery; - private final BusinessPlanService businessPlanService; - private final AiReportQuery aiReportQuery; - private final AiReportGrader aiReportGrader; + private final BusinessPlanQueryPort businessPlanQuery; + private final BusinessPlanUseCase businessPlanService; + private final AiReportQueryPort aiReportQuery; + private final AiReportGradingPort aiReportGrader; private final ObjectMapper objectMapper; - private final OcrProvider ocrProvider; + private final OcrProviderPort ocrProvider; private final AiReportResponseParser responseParser; private final BusinessPlanContentExtractor contentExtractor; + private final LlmGenerator llmGenerator; @Override - public AiReportResponse gradeBusinessPlan(Long planId, Long memberId) { + public AiReportResult gradeBusinessPlan(Long planId, Long memberId) { BusinessPlan plan = businessPlanQuery.findByIdOrThrow(planId); checkBusinessPlanOwned(plan, memberId); checkBusinessPlanWritingCompleted(plan); - AiReportResponse gradingResult = aiReportGrader.gradeContent(contentExtractor.extractContent(plan)); + AiReportResult gradingResult = aiReportGrader.gradeContent(contentExtractor.extractContent(plan)); String rawJsonString = getRawJsonAiReportResponseFromGradingResult(gradingResult); @@ -55,9 +57,9 @@ public AiReportResponse gradeBusinessPlan(Long planId, Long memberId) { } @Override - public AiReportResponse createAndGradePdfBusinessPlan(String title, String pdfUrl, Long memberId) { + public AiReportResult createAndGradePdfBusinessPlan(String title, String pdfUrl, Long memberId) { - BusinessPlanResponse.Result businessPlanResult = businessPlanService.createBusinessPlanWithPdf( + BusinessPlanResult.Result businessPlanResult = businessPlanService.createBusinessPlanWithPdf( title, pdfUrl, memberId @@ -67,7 +69,9 @@ public AiReportResponse createAndGradePdfBusinessPlan(String title, String pdfUr String pdfText = ocrProvider.ocrPdfTextByUrl(pdfUrl); - AiReportResponse gradingResult = aiReportGrader.gradeContent(pdfText); + // PDF의 경우 기존 한 번에 LLM에 돌리는 방식을 사용 + String llmResponse = llmGenerator.generateReport(pdfText); + AiReportResult gradingResult = responseParser.parse(llmResponse); String rawJsonString = getRawJsonAiReportResponseFromGradingResult(gradingResult); @@ -78,7 +82,7 @@ public AiReportResponse createAndGradePdfBusinessPlan(String title, String pdfUr @Override @Transactional(readOnly = true) - public AiReportResponse getAiReport(Long planId, Long memberId) { + public AiReportResult getAiReport(Long planId, Long memberId) { BusinessPlan plan = businessPlanQuery.findByIdOrThrow(planId); checkBusinessPlanOwned(plan, memberId); @@ -88,7 +92,7 @@ public AiReportResponse getAiReport(Long planId, Long memberId) { return responseParser.toResponse(aiReport); } - private String getRawJsonAiReportResponseFromGradingResult(AiReportResponse gradingResult) { + private String getRawJsonAiReportResponseFromGradingResult(AiReportResult gradingResult) { JsonNode gradingJsonNode = responseParser.convertToJsonNode(gradingResult); String rawJsonString; try { diff --git a/src/main/java/starlight/application/aireport/provided/AiReportUseCase.java b/src/main/java/starlight/application/aireport/provided/AiReportUseCase.java new file mode 100644 index 0000000..b2dc4fd --- /dev/null +++ b/src/main/java/starlight/application/aireport/provided/AiReportUseCase.java @@ -0,0 +1,12 @@ +package starlight.application.aireport.provided; + +import starlight.application.aireport.provided.dto.AiReportResult; + +public interface AiReportUseCase { + AiReportResult gradeBusinessPlan(Long businessPlanId, Long memberId); + + AiReportResult createAndGradePdfBusinessPlan(String title, String pdfUrl, Long memberId); + + AiReportResult getAiReport(Long businessPlanId, Long memberId); +} + diff --git a/src/main/java/starlight/application/aireport/provided/dto/AiReportResult.java b/src/main/java/starlight/application/aireport/provided/dto/AiReportResult.java new file mode 100644 index 0000000..a5135d4 --- /dev/null +++ b/src/main/java/starlight/application/aireport/provided/dto/AiReportResult.java @@ -0,0 +1,67 @@ +package starlight.application.aireport.provided.dto; + +import com.fasterxml.jackson.annotation.JsonRawValue; +import java.util.List; + +/** + * AI 리포트 응답 DTO + * LLM 채점 결과와 API 응답을 모두 담는 통합 DTO + */ +public record AiReportResult( + Long id, // null 가능 (LLM 결과 파싱 시에는 null) + Long businessPlanId, // null 가능 (LLM 결과 파싱 시에는 null) + Integer totalScore, + Integer problemRecognitionScore, + Integer feasibilityScore, + Integer growthStrategyScore, + Integer teamCompetenceScore, + List sectionScores, + List strengths, + List weaknesses +) { + public record SectionScoreDetailResponse( + String sectionType, + @JsonRawValue String gradingListScores + ) {} + + public record StrengthWeakness( + String title, + String content + ) {} + + /** + * LLM 결과만으로 AiReportResponse 생성 (id, businessPlanId는 null) + */ + public static AiReportResult fromGradingResult( + Integer problemRecognitionScore, + Integer feasibilityScore, + Integer growthStrategyScore, + Integer teamCompetenceScore, + List sectionScores, + List strengths, + List weaknesses + ) { + Integer totalScore = sumTotalScore(problemRecognitionScore, feasibilityScore, growthStrategyScore, teamCompetenceScore); + + return new AiReportResult( + null, + null, + totalScore, + problemRecognitionScore, + feasibilityScore, + growthStrategyScore, + teamCompetenceScore, + sectionScores, + strengths, + weaknesses + ); + } + + private static Integer sumTotalScore(Integer problemRecognitionScore, Integer feasibilityScore, Integer growthStrategyScore, Integer teamCompetenceScore) { + return (problemRecognitionScore != null ? problemRecognitionScore : 0) + + (feasibilityScore != null ? feasibilityScore : 0) + + (growthStrategyScore != null ? growthStrategyScore : 0) + + (teamCompetenceScore != null ? teamCompetenceScore : 0); + } +} + diff --git a/src/main/java/starlight/application/aireport/required/AiReportCommandPort.java b/src/main/java/starlight/application/aireport/required/AiReportCommandPort.java new file mode 100644 index 0000000..bf6318e --- /dev/null +++ b/src/main/java/starlight/application/aireport/required/AiReportCommandPort.java @@ -0,0 +1,4 @@ +package starlight.application.aireport.required; + +public interface AiReportCommandPort { +} diff --git a/src/main/java/starlight/application/aireport/required/AiReportQueryPort.java b/src/main/java/starlight/application/aireport/required/AiReportQueryPort.java new file mode 100644 index 0000000..485c6c9 --- /dev/null +++ b/src/main/java/starlight/application/aireport/required/AiReportQueryPort.java @@ -0,0 +1,11 @@ +package starlight.application.aireport.required; + +import starlight.domain.aireport.entity.AiReport; + +import java.util.Optional; + +public interface AiReportQueryPort { + AiReport save(AiReport aiReport); + Optional findByBusinessPlanId(Long businessPlanId); +} + diff --git a/src/main/java/starlight/application/aireport/required/OcrProviderPort.java b/src/main/java/starlight/application/aireport/required/OcrProviderPort.java new file mode 100644 index 0000000..6fe3adb --- /dev/null +++ b/src/main/java/starlight/application/aireport/required/OcrProviderPort.java @@ -0,0 +1,10 @@ +package starlight.application.aireport.required; + +import starlight.shared.dto.infrastructure.OcrResponse; + +public interface OcrProviderPort { + + OcrResponse ocrPdfByUrl(String pdfUrl) ; + + String ocrPdfTextByUrl(String pdfUrl); +} diff --git a/src/main/java/starlight/application/aireport/required/PresignedUrlProviderPort.java b/src/main/java/starlight/application/aireport/required/PresignedUrlProviderPort.java new file mode 100644 index 0000000..4417c3e --- /dev/null +++ b/src/main/java/starlight/application/aireport/required/PresignedUrlProviderPort.java @@ -0,0 +1,10 @@ +package starlight.application.aireport.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/aireport/required/ReportGraderPort.java b/src/main/java/starlight/application/aireport/required/ReportGraderPort.java new file mode 100644 index 0000000..42a3539 --- /dev/null +++ b/src/main/java/starlight/application/aireport/required/ReportGraderPort.java @@ -0,0 +1,10 @@ +package starlight.application.aireport.required; + +import starlight.application.aireport.provided.dto.AiReportResult; + +public interface AiReportGraderPort { + AiReportResult gradeContentWithSectionAgents(String content); + private AiReportResult gradeContentWithSections(String content) { } + private AiReportResult gradeContent(String content) { } +} + diff --git a/src/main/java/starlight/application/businessplan/BusinessPlanService.java b/src/main/java/starlight/application/businessplan/BusinessPlanService.java new file mode 100644 index 0000000..65ac473 --- /dev/null +++ b/src/main/java/starlight/application/businessplan/BusinessPlanService.java @@ -0,0 +1,256 @@ +package starlight.application.businessplan; + +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 org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import starlight.application.businessplan.provided.dto.BusinessPlanResult; +import starlight.application.businessplan.provided.dto.SubSectionResult; +import starlight.application.businessplan.provided.BusinessPlanUseCase; +import starlight.application.businessplan.required.BusinessPlanQueryPort; +import starlight.application.businessplan.required.ChecklistGraderPort; +import starlight.application.businessplan.util.PlainTextExtractUtils; +import starlight.application.businessplan.util.SubSectionSupportUtils; +import starlight.application.member.required.MemberQueryPort; +import starlight.domain.businessplan.entity.*; +import starlight.domain.businessplan.enumerate.PlanStatus; +import starlight.domain.member.entity.Member; +import starlight.shared.enumerate.SectionType; +import starlight.domain.businessplan.enumerate.SubSectionType; +import starlight.domain.businessplan.exception.BusinessPlanErrorType; +import starlight.domain.businessplan.exception.BusinessPlanException; + +import java.util.Arrays; +import java.util.List; +import java.util.Objects; + +@Service +@RequiredArgsConstructor +@Transactional +public class BusinessPlanService implements BusinessPlanUseCase { + + private final BusinessPlanQueryPort businessPlanQuery; + private final MemberQueryPort memberQuery; + private final ChecklistGraderPort checklistGrader; + private final ObjectMapper objectMapper; + + @Override + public BusinessPlanResult.Result createBusinessPlan(Long memberId) { + Member member = memberQuery.findByIdOrThrow(memberId); + + String planTitle = member.getName() == null ? "제목 없는 사업계획서" : member.getName() + "의 사업계획서"; + + BusinessPlan plan = BusinessPlan.create(planTitle, memberId); + + return BusinessPlanResult.Result.from(businessPlanQuery.save(plan), "Business plan created"); + } + + @Override + public BusinessPlanResult.Result createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId) { + BusinessPlan plan = BusinessPlan.createWithPdf( + title, + memberId, + pdfUrl + ); + + return BusinessPlanResult.Result.from(businessPlanQuery.save(plan), "PDF Business plan created"); + } + + @Override + @Transactional(readOnly = true) + public BusinessPlanResult.Result getBusinessPlanInfo(Long planId, Long memberId) { + BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); + + return BusinessPlanResult.Result.from(plan, "Business plan retrieved"); + } + + @Override + @Transactional(readOnly = true) + public BusinessPlanResult.Detail getBusinessPlanDetail(Long planId, Long memberId) { + BusinessPlan plan = businessPlanQuery.findWithAllSubSectionsOrThrow(planId); + if (!plan.isOwnedBy(memberId)) { + throw new BusinessPlanException(BusinessPlanErrorType.UNAUTHORIZED_ACCESS); + } + + List subSectionDetailList = Arrays.stream(SubSectionType.values()) + .map(type -> getSectionByPlanAndType(plan, type.getSectionType()).getSubSectionByType(type)) + .filter(Objects::nonNull) + .map(SubSectionResult.Detail::from) + .toList(); + + return BusinessPlanResult.Detail.from(plan, subSectionDetailList); + } + + @Override + @Transactional(readOnly = true) + public BusinessPlanResult.PreviewPage getBusinessPlanList(Long memberId, Pageable pageable) { + Page page = businessPlanQuery.findPreviewPage(memberId, pageable); + List content = page.getContent().stream() + .map(BusinessPlanResult.Preview::from) + .toList(); + + return BusinessPlanResult.PreviewPage.from(content, page); + } + + @Override + public String updateBusinessPlanTitle(Long planId, String title, Long memberId) { + BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); + + plan.updateTitle(title); + + businessPlanQuery.save(plan); + + return plan.getTitle(); + } + + @Override + public BusinessPlanResult.Result deleteBusinessPlan(Long planId, Long memberId) { + BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); + + BusinessPlanResult.Result result = BusinessPlanResult.Result.from(plan, "Business plan deleted"); + businessPlanQuery.delete(plan); + + return result; + } + + @Override + public SubSectionResult.Result upsertSubSection( + Long planId, + JsonNode jsonNode, + List checks, + SubSectionType subSectionType, + Long memberId + ) { + BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); + + SectionType sectionType = subSectionType.getSectionType(); + BaseSection section = getSectionByPlanAndType(plan, sectionType); + SubSection subSection = section.getSubSectionByType(subSectionType); + + String rawJsonStr = getSerializedJsonNodesWithUpdatedChecks(jsonNode, checks); + String content = PlainTextExtractUtils.extractPlainText(objectMapper, jsonNode); + + String message; + + if (subSection == null) { + SubSection newSubSection = SubSection.create(subSectionType, content, rawJsonStr, checks); + section.putSubSection(newSubSection); + message = "Subsection created"; + } else { + subSection.update(content, rawJsonStr, checks); + message = "Subsection updated"; + } + + if (plan.areWritingCompleted()) { + plan.updateStatus(PlanStatus.WRITTEN_COMPLETED); + message = "Subsection writing completed"; + } + + BusinessPlan savedPlan = businessPlanQuery.save(plan); + SubSection persistedSubSection = getSectionByPlanAndType(savedPlan, sectionType) + .getSubSectionByType(subSectionType); + + return SubSectionResult.Result.from(persistedSubSection, message); + } + + @Override + @Transactional(readOnly = true) + public SubSectionResult.Detail getSubSectionDetail(Long planId, SubSectionType subSectionType, Long memberId) { + BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); + + SectionType sectionType = subSectionType.getSectionType(); + SubSection subSection = getSectionByPlanAndType(plan, sectionType).getSubSectionByType(subSectionType); + if (subSection == null) { + throw new BusinessPlanException(BusinessPlanErrorType.SUBSECTION_NOT_FOUND); + } + + return SubSectionResult.Detail.from(subSection); + } + + @Override + public List checkAndUpdateSubSection( + Long planId, + JsonNode jsonNode, + SubSectionType subSectionType, + Long memberId) { + BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); + + SectionType sectionType = subSectionType.getSectionType(); + SubSection subSection = getSectionByPlanAndType(plan, sectionType).getSubSectionByType(subSectionType); + if (subSection == null) { + throw new BusinessPlanException(BusinessPlanErrorType.SUBSECTION_NOT_FOUND); + } + + String content = PlainTextExtractUtils.extractPlainText(objectMapper, jsonNode); + + List checks = checklistGrader.check(subSectionType, content); + + SubSectionSupportUtils.requireSize(checks, SubSection.getCHECKLIST_SIZE()); + String rawJsonStr = getSerializedJsonNodesWithUpdatedChecks(jsonNode, checks); + + subSection.update(content, rawJsonStr, checks); + + businessPlanQuery.save(plan); + + return checks; + } + + @Override + public SubSectionResult.Result deleteSubSection(Long planId, SubSectionType subSectionType, Long memberId) { + BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); + + SectionType sectionType = subSectionType.getSectionType(); + BaseSection section = getSectionByPlanAndType(plan, sectionType); + SubSection target = section.getSubSectionByType(subSectionType); + if (target == null) { + throw new BusinessPlanException(BusinessPlanErrorType.SUBSECTION_NOT_FOUND); + } + SubSectionResult.Result result = SubSectionResult.Result.from(target, "Subsection deleted"); + section.removeSubSection(subSectionType); + + businessPlanQuery.save(plan); + + return result; + } + + private String getSerializedJsonNodesWithUpdatedChecks(JsonNode jsonNode, List checks) { + + ObjectNode updatedJsonNode = (ObjectNode) objectMapper.valueToTree(jsonNode); + + ArrayNode checkListArray; + if (updatedJsonNode.has("checks") && updatedJsonNode.get("checks").isArray()) { + checkListArray = (ArrayNode) updatedJsonNode.get("checks"); + checkListArray.removeAll(); + + for (Boolean check : checks) { + checkListArray.add(check); + } + } + + return SubSectionSupportUtils.serializeJsonNodeSafely(objectMapper, updatedJsonNode); + } + + private BusinessPlan getOwnedBusinessPlanOrThrow(Long planId, Long memberId) { + BusinessPlan businessPlan = businessPlanQuery.findByIdOrThrow(planId); + if (!businessPlan.isOwnedBy(memberId)) { + throw new BusinessPlanException(BusinessPlanErrorType.UNAUTHORIZED_ACCESS); + } + return businessPlan; + } + + private BaseSection getSectionByPlanAndType(BusinessPlan plan, SectionType type) { + return switch (type) { + case OVERVIEW -> plan.getOverview(); + case PROBLEM_RECOGNITION -> plan.getProblemRecognition(); + case FEASIBILITY -> plan.getFeasibility(); + case GROWTH_STRATEGY -> plan.getGrowthTactic(); + case TEAM_COMPETENCE -> plan.getTeamCompetence(); + default -> throw new IllegalArgumentException("Unsupported section: " + type); + }; + } +} diff --git a/src/main/java/starlight/application/businessplan/provided/BusinessPlanUseCase.java b/src/main/java/starlight/application/businessplan/provided/BusinessPlanUseCase.java new file mode 100644 index 0000000..2c9d431 --- /dev/null +++ b/src/main/java/starlight/application/businessplan/provided/BusinessPlanUseCase.java @@ -0,0 +1,38 @@ +package starlight.application.businessplan.provided; + +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.data.domain.Pageable; +import starlight.application.businessplan.provided.dto.BusinessPlanResult; +import starlight.application.businessplan.provided.dto.SubSectionResult; +import starlight.domain.businessplan.enumerate.SubSectionType; + +import java.util.List; + +public interface BusinessPlanUseCase { + + BusinessPlanResult.Result createBusinessPlan(Long memberId); + + BusinessPlanResult.Result createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId); + + BusinessPlanResult.Result getBusinessPlanInfo(Long planId, Long memberId); + + BusinessPlanResult.Detail getBusinessPlanDetail(Long planId, Long memberId); + + BusinessPlanResult.PreviewPage getBusinessPlanList(Long memberId, Pageable pageable); + + String updateBusinessPlanTitle(Long planId, String title, Long memberId); + + BusinessPlanResult.Result deleteBusinessPlan(Long planId, Long memberId); + + SubSectionResult.Result upsertSubSection(Long planId, JsonNode jsonNode, List checks, + SubSectionType subSectionType, Long memberId); + + SubSectionResult.Detail getSubSectionDetail(Long planId, SubSectionType subSectionType, Long memberId); + + List checkAndUpdateSubSection(Long planId, JsonNode jsonNode, SubSectionType subSectionType, + Long memberId); + + SubSectionResult.Result deleteSubSection(Long planId, SubSectionType subSectionType, Long memberId); + + +} diff --git a/src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResult.java b/src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResult.java new file mode 100644 index 0000000..b4c6a97 --- /dev/null +++ b/src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResult.java @@ -0,0 +1,93 @@ +package starlight.application.businessplan.provided.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import org.springframework.data.domain.Page; +import starlight.domain.businessplan.entity.BusinessPlan; +import starlight.domain.businessplan.enumerate.PlanStatus; + +import java.time.LocalDateTime; +import java.util.List; + +public record BusinessPlanResult() { + + public record Result( + Long businessPlanId, + String title, + PlanStatus planStatus, + String message + ) { + public static Result from(BusinessPlan businessPlan, String message) { + return new Result( + businessPlan.getId(), + businessPlan.getTitle(), + businessPlan.getPlanStatus(), + message + ); + } + } + + public record Detail( + Long businessPlanId, + String title, + PlanStatus planStatus, + List subSectionDetailList + ) { + public static Detail from( + BusinessPlan businessPlan, + List subSectionDetailList + ) { + return new Detail( + businessPlan.getId(), + businessPlan.getTitle(), + businessPlan.getPlanStatus(), + subSectionDetailList + ); + } + } + + public record Preview( + Long businessPlanId, + String title, + String pdfUrl, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") LocalDateTime lastSavedAt, + PlanStatus planStatus + ) { + public static Preview from(BusinessPlan businessPlan) { + LocalDateTime lastSavedAt = businessPlan.getModifiedAt() != null + ? businessPlan.getModifiedAt() + : businessPlan.getCreatedAt(); + + return new Preview( + businessPlan.getId(), + businessPlan.getTitle(), + businessPlan.isPdfBased() ? businessPlan.getPdfUrl() : null, + lastSavedAt, + businessPlan.getPlanStatus() + ); + } + } + + public record PreviewPage( + List content, + int page, + int size, + int totalPages, + long totalElements, + int numberOfElements, + boolean first, + boolean last + ) { + public static PreviewPage from(List content, Page page) { + return new BusinessPlanResult.PreviewPage( + content, + page.getNumber() + 1, + page.getSize(), + page.getTotalPages(), + page.getTotalElements(), + page.getNumberOfElements(), + page.isFirst(), + page.isLast() + ); + } + } +} diff --git a/src/main/java/starlight/application/businessplan/provided/dto/SubSectionResult.java b/src/main/java/starlight/application/businessplan/provided/dto/SubSectionResult.java new file mode 100644 index 0000000..8607e7c --- /dev/null +++ b/src/main/java/starlight/application/businessplan/provided/dto/SubSectionResult.java @@ -0,0 +1,39 @@ +package starlight.application.businessplan.provided.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import starlight.domain.businessplan.entity.SubSection; +import starlight.domain.businessplan.enumerate.SubSectionType; + +public record SubSectionResult() { + + public record Result( + SubSectionType subSectionType, + Long subSectionId, + String message + ) { + public static Result from( + SubSection subSection, + String message + ) { + return new Result( + subSection.getSubSectionType(), + subSection.getId(), + message + ); + } + } + + public record Detail( + SubSectionType subSectionType, + Long subSectionId, + JsonNode content + ) { + public static Detail from(SubSection subSection) { + return new Detail( + subSection.getSubSectionType(), + subSection.getId(), + subSection.getRawJson().asTree() + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/starlight/application/businessplan/required/BusinessPlanCommandPort.java b/src/main/java/starlight/application/businessplan/required/BusinessPlanCommandPort.java new file mode 100644 index 0000000..24df818 --- /dev/null +++ b/src/main/java/starlight/application/businessplan/required/BusinessPlanCommandPort.java @@ -0,0 +1,4 @@ +package starlight.application.businessplan.required; + +public interface BusinessPlanCommandPort { +} diff --git a/src/main/java/starlight/application/businessplan/required/BusinessPlanQueryPort.java b/src/main/java/starlight/application/businessplan/required/BusinessPlanQueryPort.java new file mode 100644 index 0000000..11246bd --- /dev/null +++ b/src/main/java/starlight/application/businessplan/required/BusinessPlanQueryPort.java @@ -0,0 +1,18 @@ +package starlight.application.businessplan.required; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Page; +import starlight.domain.businessplan.entity.BusinessPlan; + +public interface BusinessPlanQueryPort { + + BusinessPlan findByIdOrThrow(Long id); + + BusinessPlan findWithAllSubSectionsOrThrow(Long id); + + BusinessPlan save(BusinessPlan businessPlan); + + void delete(BusinessPlan businessPlan); + + Page findPreviewPage(Long memberId, Pageable pageable); +} diff --git a/src/main/java/starlight/application/businessplan/required/ChecklistGraderPort.java b/src/main/java/starlight/application/businessplan/required/ChecklistGraderPort.java new file mode 100644 index 0000000..157179f --- /dev/null +++ b/src/main/java/starlight/application/businessplan/required/ChecklistGraderPort.java @@ -0,0 +1,20 @@ +package starlight.application.businessplan.required; + +import starlight.domain.businessplan.enumerate.SubSectionType; + +import java.util.List; + +public interface ChecklistGraderPort { + + /** + * 서브섹션 내용을 체크리스트 기준에 따라 체크합니다. + * + * @param subSectionType 서브섹션 타입 + * @param content 서브섹션 내용 + * @return 체크리스트 결과 + */ + List check( + SubSectionType subSectionType, + String content + ); +} diff --git a/src/main/java/starlight/application/businessplan/required/SpellCheckerPort.java b/src/main/java/starlight/application/businessplan/required/SpellCheckerPort.java new file mode 100644 index 0000000..3c3bb66 --- /dev/null +++ b/src/main/java/starlight/application/businessplan/required/SpellCheckerPort.java @@ -0,0 +1,12 @@ +package starlight.application.businessplan.required; + +import starlight.adapter.businessplan.spellcheck.dto.Finding; + +import java.util.List; + +public interface SpellCheckerPort { + + List check(String sentence); + + String applyTopSuggestions(String original, List findings); +} \ No newline at end of file diff --git a/src/main/java/starlight/application/expertReport/provided/ExpertReportServiceUseCase.java b/src/main/java/starlight/application/expertReport/provided/ExpertReportUseCase.java similarity index 100% rename from src/main/java/starlight/application/expertReport/provided/ExpertReportServiceUseCase.java rename to src/main/java/starlight/application/expertReport/provided/ExpertReportUseCase.java diff --git a/src/main/java/starlight/application/member/CredentialServiceImpl.java b/src/main/java/starlight/application/member/CredentialService.java similarity index 100% rename from src/main/java/starlight/application/member/CredentialServiceImpl.java rename to src/main/java/starlight/application/member/CredentialService.java diff --git a/src/main/java/starlight/application/member/MemberQueryService.java b/src/main/java/starlight/application/member/MemberService.java similarity index 100% rename from src/main/java/starlight/application/member/MemberQueryService.java rename to src/main/java/starlight/application/member/MemberService.java diff --git a/src/main/java/starlight/application/member/provided/CredentialService.java b/src/main/java/starlight/application/member/provided/CredentialUseCase.java similarity index 100% rename from src/main/java/starlight/application/member/provided/CredentialService.java rename to src/main/java/starlight/application/member/provided/CredentialUseCase.java diff --git a/src/main/java/starlight/application/member/provided/MemberQueryUseCase.java b/src/main/java/starlight/application/member/provided/MemberUseCase.java similarity index 100% rename from src/main/java/starlight/application/member/provided/MemberQueryUseCase.java rename to src/main/java/starlight/application/member/provided/MemberUseCase.java From 87c46ce97671be5acee9eab86633c3310afb8a6a Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Mon, 5 Jan 2026 22:41:38 +0900 Subject: [PATCH 03/74] =?UTF-8?q?[SRLT-124]=20Feat:=20Hexagonal=20Architec?= =?UTF-8?q?ture=20=EC=A0=81=EC=9A=A9=EC=9D=84=20=EC=9C=84=ED=95=9C=20Port?= =?UTF-8?q?=20=EB=B0=8F=20UseCase=20=EC=9D=B8=ED=84=B0=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aireport/provided/AiReportUseCase.java | 3 +- .../required/AiReportCommandPort.java | 3 ++ .../aireport/required/AiReportQueryPort.java | 1 - .../aireport/required/ReportGraderPort.java | 10 ++++--- .../businessplan/BusinessPlanService.java | 28 ++++++++++--------- .../required/BusinessPlanCommandPort.java | 6 ++++ .../required/BusinessPlanQueryPort.java | 4 --- .../required/ChecklistGraderPort.java | 12 +------- 8 files changed, 32 insertions(+), 35 deletions(-) diff --git a/src/main/java/starlight/application/aireport/provided/AiReportUseCase.java b/src/main/java/starlight/application/aireport/provided/AiReportUseCase.java index b2dc4fd..1f8f4a2 100644 --- a/src/main/java/starlight/application/aireport/provided/AiReportUseCase.java +++ b/src/main/java/starlight/application/aireport/provided/AiReportUseCase.java @@ -8,5 +8,4 @@ public interface AiReportUseCase { AiReportResult createAndGradePdfBusinessPlan(String title, String pdfUrl, Long memberId); AiReportResult getAiReport(Long businessPlanId, Long memberId); -} - +} \ No newline at end of file diff --git a/src/main/java/starlight/application/aireport/required/AiReportCommandPort.java b/src/main/java/starlight/application/aireport/required/AiReportCommandPort.java index bf6318e..564958d 100644 --- a/src/main/java/starlight/application/aireport/required/AiReportCommandPort.java +++ b/src/main/java/starlight/application/aireport/required/AiReportCommandPort.java @@ -1,4 +1,7 @@ package starlight.application.aireport.required; +import starlight.domain.aireport.entity.AiReport; + public interface AiReportCommandPort { + AiReport save(AiReport aiReport); } diff --git a/src/main/java/starlight/application/aireport/required/AiReportQueryPort.java b/src/main/java/starlight/application/aireport/required/AiReportQueryPort.java index 485c6c9..8507071 100644 --- a/src/main/java/starlight/application/aireport/required/AiReportQueryPort.java +++ b/src/main/java/starlight/application/aireport/required/AiReportQueryPort.java @@ -5,7 +5,6 @@ import java.util.Optional; public interface AiReportQueryPort { - AiReport save(AiReport aiReport); Optional findByBusinessPlanId(Long businessPlanId); } diff --git a/src/main/java/starlight/application/aireport/required/ReportGraderPort.java b/src/main/java/starlight/application/aireport/required/ReportGraderPort.java index 42a3539..4f097b0 100644 --- a/src/main/java/starlight/application/aireport/required/ReportGraderPort.java +++ b/src/main/java/starlight/application/aireport/required/ReportGraderPort.java @@ -1,10 +1,12 @@ package starlight.application.aireport.required; import starlight.application.aireport.provided.dto.AiReportResult; +import starlight.shared.enumerate.SectionType; -public interface AiReportGraderPort { - AiReportResult gradeContentWithSectionAgents(String content); - private AiReportResult gradeContentWithSections(String content) { } - private AiReportResult gradeContent(String content) { } +import java.util.Map; + +public interface ReportGraderPort { + AiReportResult gradeWithSectionAgents(Map sectionContents, String fullContent); + AiReportResult gradeWithFullPrompt(String content); } diff --git a/src/main/java/starlight/application/businessplan/BusinessPlanService.java b/src/main/java/starlight/application/businessplan/BusinessPlanService.java index 65ac473..c26181c 100644 --- a/src/main/java/starlight/application/businessplan/BusinessPlanService.java +++ b/src/main/java/starlight/application/businessplan/BusinessPlanService.java @@ -12,6 +12,7 @@ import starlight.application.businessplan.provided.dto.BusinessPlanResult; import starlight.application.businessplan.provided.dto.SubSectionResult; import starlight.application.businessplan.provided.BusinessPlanUseCase; +import starlight.application.businessplan.required.BusinessPlanCommandPort; import starlight.application.businessplan.required.BusinessPlanQueryPort; import starlight.application.businessplan.required.ChecklistGraderPort; import starlight.application.businessplan.util.PlainTextExtractUtils; @@ -34,20 +35,21 @@ @Transactional public class BusinessPlanService implements BusinessPlanUseCase { - private final BusinessPlanQueryPort businessPlanQuery; - private final MemberQueryPort memberQuery; + private final BusinessPlanCommandPort businessPlanCommandPort; + private final BusinessPlanQueryPort businessPlanQueryPort; + private final MemberQueryPort memberQueryPort; private final ChecklistGraderPort checklistGrader; private final ObjectMapper objectMapper; @Override public BusinessPlanResult.Result createBusinessPlan(Long memberId) { - Member member = memberQuery.findByIdOrThrow(memberId); + Member member = memberQueryPort.findByIdOrThrow(memberId); String planTitle = member.getName() == null ? "제목 없는 사업계획서" : member.getName() + "의 사업계획서"; BusinessPlan plan = BusinessPlan.create(planTitle, memberId); - return BusinessPlanResult.Result.from(businessPlanQuery.save(plan), "Business plan created"); + return BusinessPlanResult.Result.from(businessPlanCommandPort.save(plan), "Business plan created"); } @Override @@ -58,7 +60,7 @@ public BusinessPlanResult.Result createBusinessPlanWithPdf(String title, String pdfUrl ); - return BusinessPlanResult.Result.from(businessPlanQuery.save(plan), "PDF Business plan created"); + return BusinessPlanResult.Result.from(businessPlanCommandPort.save(plan), "PDF Business plan created"); } @Override @@ -72,7 +74,7 @@ public BusinessPlanResult.Result getBusinessPlanInfo(Long planId, Long memberId) @Override @Transactional(readOnly = true) public BusinessPlanResult.Detail getBusinessPlanDetail(Long planId, Long memberId) { - BusinessPlan plan = businessPlanQuery.findWithAllSubSectionsOrThrow(planId); + BusinessPlan plan = businessPlanQueryPort.findWithAllSubSectionsOrThrow(planId); if (!plan.isOwnedBy(memberId)) { throw new BusinessPlanException(BusinessPlanErrorType.UNAUTHORIZED_ACCESS); } @@ -89,7 +91,7 @@ public BusinessPlanResult.Detail getBusinessPlanDetail(Long planId, Long memberI @Override @Transactional(readOnly = true) public BusinessPlanResult.PreviewPage getBusinessPlanList(Long memberId, Pageable pageable) { - Page page = businessPlanQuery.findPreviewPage(memberId, pageable); + Page page = businessPlanQueryPort.findPreviewPage(memberId, pageable); List content = page.getContent().stream() .map(BusinessPlanResult.Preview::from) .toList(); @@ -103,7 +105,7 @@ public String updateBusinessPlanTitle(Long planId, String title, Long memberId) plan.updateTitle(title); - businessPlanQuery.save(plan); + businessPlanCommandPort.save(plan); return plan.getTitle(); } @@ -113,7 +115,7 @@ public BusinessPlanResult.Result deleteBusinessPlan(Long planId, Long memberId) BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); BusinessPlanResult.Result result = BusinessPlanResult.Result.from(plan, "Business plan deleted"); - businessPlanQuery.delete(plan); + businessPlanCommandPort.delete(plan); return result; } @@ -151,7 +153,7 @@ public SubSectionResult.Result upsertSubSection( message = "Subsection writing completed"; } - BusinessPlan savedPlan = businessPlanQuery.save(plan); + BusinessPlan savedPlan = businessPlanCommandPort.save(plan); SubSection persistedSubSection = getSectionByPlanAndType(savedPlan, sectionType) .getSubSectionByType(subSectionType); @@ -195,7 +197,7 @@ public List checkAndUpdateSubSection( subSection.update(content, rawJsonStr, checks); - businessPlanQuery.save(plan); + businessPlanCommandPort.save(plan); return checks; } @@ -213,7 +215,7 @@ public SubSectionResult.Result deleteSubSection(Long planId, SubSectionType subS SubSectionResult.Result result = SubSectionResult.Result.from(target, "Subsection deleted"); section.removeSubSection(subSectionType); - businessPlanQuery.save(plan); + businessPlanCommandPort.save(plan); return result; } @@ -236,7 +238,7 @@ private String getSerializedJsonNodesWithUpdatedChecks(JsonNode jsonNode, List findPreviewPage(Long memberId, Pageable pageable); } diff --git a/src/main/java/starlight/application/businessplan/required/ChecklistGraderPort.java b/src/main/java/starlight/application/businessplan/required/ChecklistGraderPort.java index 157179f..0646b97 100644 --- a/src/main/java/starlight/application/businessplan/required/ChecklistGraderPort.java +++ b/src/main/java/starlight/application/businessplan/required/ChecklistGraderPort.java @@ -6,15 +6,5 @@ public interface ChecklistGraderPort { - /** - * 서브섹션 내용을 체크리스트 기준에 따라 체크합니다. - * - * @param subSectionType 서브섹션 타입 - * @param content 서브섹션 내용 - * @return 체크리스트 결과 - */ - List check( - SubSectionType subSectionType, - String content - ); + List check(SubSectionType subSectionType, String content); } From dae52958f835ebdd61372b05ba9c304558db7ac7 Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Mon, 5 Jan 2026 22:41:41 +0900 Subject: [PATCH 04/74] =?UTF-8?q?[SRLT-124]=20Feat:=20AI=20=EB=A6=AC?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=20=EC=B1=84=EC=A0=90=20=EC=95=84=ED=82=A4?= =?UTF-8?q?=ED=85=8D=EC=B2=98=20=EA=B0=9C=EC=84=A0=20-=20=EC=84=B9?= =?UTF-8?q?=EC=85=98=EB=B3=84=20=EC=97=90=EC=9D=B4=EC=A0=84=ED=8A=B8=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8A=88=ED=8D=BC=EB=B0=94=EC=9D=B4=EC=A0=80=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aireport/report/SpringAiReportGrader.java | 264 +++++++++++------- .../report/agent/FullReportGradeAgent.java | 6 +- .../report/agent/SectionGradeAgent.java | 10 +- .../impl/SpringAiFullReportGradeAgent.java | 77 +++-- .../agent/impl/SpringAiSectionGradeAgent.java | 21 +- .../SectionGradingCircuitBreaker.java | 2 +- .../report/config/SectionAdvisorConfig.java | 16 +- .../report/dto/SectionGradingResult.java | 2 +- .../report/provider/ReportPromptProvider.java | 66 +---- .../provider/SpringAiAdvisorProvider.java | 2 +- .../report/supervisor/ReportSupervisor.java | 10 +- .../report/util/AiReportResponseParser.java | 221 ++++++++++++--- 12 files changed, 436 insertions(+), 261 deletions(-) diff --git a/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java b/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java index 2765417..5e94c3b 100644 --- a/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java +++ b/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java @@ -1,11 +1,12 @@ -package starlight.adapter.aireport.reportgrader; +package starlight.adapter.aireport.report; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.stereotype.Component; -import starlight.adapter.aireport.reportgrader.agent.SectionGradeAgent; -import starlight.adapter.aireport.reportgrader.dto.SectionGradingResult; -import starlight.adapter.aireport.reportgrader.supervisor.ReportSupervisor; +import starlight.adapter.aireport.report.agent.FullReportGradeAgent; +import starlight.adapter.aireport.report.agent.SectionGradeAgent; +import starlight.adapter.aireport.report.dto.SectionGradingResult; +import starlight.adapter.aireport.report.supervisor.ReportSupervisor; import starlight.application.aireport.provided.dto.AiReportResult; import starlight.application.aireport.required.ReportGraderPort; import starlight.application.businessplan.util.BusinessPlanContentExtractor; @@ -24,130 +25,191 @@ @Slf4j @Component public class SpringAiReportGrader implements ReportGraderPort { - - private final Map advisors; + + private final Map sectionGradeAgentMap; + private final FullReportGradeAgent fullReportGradeAgent; private final ReportSupervisor supervisor; private final BusinessPlanContentExtractor contentExtractor; private final Executor sectionGradingExecutor; - + public SpringAiReportGrader( - List advisorList, - ReportSupervisor supervisor, - BusinessPlanContentExtractor contentExtractor, - @Qualifier("sectionGradingExecutor") Executor sectionGradingExecutor - ) { - this.advisors = advisorList.stream() - .collect(Collectors.toMap( - SectionGradeAgent::getSectionType, - advisor -> advisor - )); + List sectionGradeAgentList, + FullReportGradeAgent fullReportGradeAgent, + ReportSupervisor supervisor, + BusinessPlanContentExtractor contentExtractor, + @Qualifier("sectionGradingExecutor") Executor sectionGradingExecutor) { + this.sectionGradeAgentMap = sectionGradeAgentList.stream() + .collect(Collectors.toMap( + SectionGradeAgent::getSectionType, + advisor -> advisor)); + this.fullReportGradeAgent = fullReportGradeAgent; this.supervisor = supervisor; this.contentExtractor = contentExtractor; this.sectionGradingExecutor = sectionGradingExecutor; } - + + /** + * PDF에서 추출한 텍스트를 한 번에 채점하는 메소드 + * 전체 프롬프트를 사용하여 LLM에 한 번에 요청하고 결과를 파싱하여 반환 + */ + @Override + public AiReportResult gradeWithFullPrompt(String content) { + log.info("전체 프롬프트를 사용한 채점 시작"); + try { + AiReportResult result = fullReportGradeAgent.gradeFullReport(content); + log.info("전체 프롬프트를 사용한 채점 완료"); + return result; + } catch (starlight.domain.aireport.exception.AiReportException e) { + log.error("전체 프롬프트 채점 중 예외 발생", e); + throw e; + } + } + + /** + * 섹션별 에이전트를 통해 채점하는 메소드 + * 에이전트 결과를 슈퍼바이저 LLM에 요청하여 결과를 파싱 + */ @Override - public AiReportResult gradeWithSectionAgents(String content) { - // 섹션별 내용 추출 (전체 텍스트에서) - Map sectionContents = contentExtractor.extractSectionContentsFromText(content); - + public AiReportResult gradeWithSectionAgents(Map sectionContents, String fullContent) { + log.info("섹션별 에이전트를 통한 채점 시작"); + + if (sectionContents == null || sectionContents.isEmpty()) { + log.error("섹션별 내용이 비어있습니다"); + throw new starlight.domain.aireport.exception.AiReportException( + starlight.domain.aireport.exception.AiReportErrorType.AI_GRADING_FAILED); + } + + if (fullContent == null || fullContent.trim().isEmpty()) { + log.error("전체 내용이 비어있습니다"); + throw new starlight.domain.aireport.exception.AiReportException( + starlight.domain.aireport.exception.AiReportErrorType.AI_GRADING_FAILED); + } + + log.debug("섹션별 내용 추출 완료. 섹션 수: {}", sectionContents.size()); + // 4개 섹션을 병렬로 채점 - List> futures = Arrays.asList( - SectionType.PROBLEM_RECOGNITION, - SectionType.FEASIBILITY, - SectionType.GROWTH_STRATEGY, - SectionType.TEAM_COMPETENCE - ).stream() - .map(sectionType -> { - SectionGradeAgent advisor = advisors.get(sectionType); - String sectionContent = sectionContents.get(sectionType); - - if (advisor != null && sectionContent != null && !sectionContent.isBlank()) { - return CompletableFuture - .supplyAsync( - () -> advisor.gradeSection(sectionContent), - sectionGradingExecutor - ) - .exceptionally(ex -> { - log.error("[{}] 채점 중 예외 발생", sectionType, ex); - return SectionGradingResult.failure(sectionType, ex.getMessage()); - }); - } else { - // 섹션 내용이 없으면 기본값 반환 - return CompletableFuture.completedFuture( - SectionGradingResult.failure(sectionType, "섹션 내용 없음") - ); - } - }) - .collect(Collectors.toList()); - + Map> futureMap = Arrays.asList( + SectionType.PROBLEM_RECOGNITION, + SectionType.FEASIBILITY, + SectionType.GROWTH_STRATEGY, + SectionType.TEAM_COMPETENCE).stream() + .collect(Collectors.toMap( + sectionType -> sectionType, + sectionType -> { + SectionGradeAgent agent = sectionGradeAgentMap.get(sectionType); + String sectionContent = sectionContents.get(sectionType); + + if (agent != null && sectionContent != null && !sectionContent.isBlank()) { + return CompletableFuture + .supplyAsync( + () -> { + try { + return agent.gradeSection(sectionContent); + } catch (Exception e) { + log.error("[{}] 섹션 채점 중 예외 발생", sectionType, e); + return SectionGradingResult.failure(sectionType, + e.getMessage()); + } + }, + sectionGradingExecutor) + .exceptionally(ex -> { + log.error("[{}] 섹션 채점 Future 예외 처리", sectionType, ex); + return SectionGradingResult.failure(sectionType, ex.getMessage()); + }); + } else { + log.warn("[{}] 섹션 내용이 없거나 Agent가 없습니다. agent={}, content={}", + sectionType, agent != null, + sectionContent != null && !sectionContent.isBlank()); + return CompletableFuture.completedFuture( + SectionGradingResult.failure(sectionType, "섹션 내용 없음")); + } + })); + // 모든 채점 완료 대기 (최대 2분) - List results = futures.stream() - .map(future -> { - try { - return future.get(2, TimeUnit.MINUTES); - } catch (Exception e) { - log.error("섹션 채점 Future 완료 실패", e); - return SectionGradingResult.failure( - SectionType.PROBLEM_RECOGNITION, - "타임아웃 또는 예외" - ); - } - }) - .collect(Collectors.toList()); - - log.info("모든 섹션 채점 완료. 성공: {}, 실패: {}", - results.stream().filter(SectionGradingResult::success).count(), - results.stream().filter(r -> !r.success()).count() - ); - + List results = futureMap.entrySet().stream() + .map(entry -> { + SectionType sectionType = entry.getKey(); + CompletableFuture future = entry.getValue(); + try { + SectionGradingResult result = future.get(2, TimeUnit.MINUTES); + log.debug("[{}] 섹션 채점 완료. 성공: {}, 점수: {}", + sectionType, result.success(), result.score()); + return result; + } catch (java.util.concurrent.TimeoutException e) { + log.error("[{}] 섹션 채점 타임아웃 (2분 초과)", sectionType); + return SectionGradingResult.failure(sectionType, "타임아웃"); + } catch (Exception e) { + log.error("[{}] 섹션 채점 Future 완료 실패", sectionType, e); + return SectionGradingResult.failure(sectionType, "예외: " + e.getMessage()); + } + }) + .collect(Collectors.toList()); + + long successCount = results.stream().filter(SectionGradingResult::success).count(); + long failureCount = results.stream().filter(r -> !r.success()).count(); + log.info("모든 섹션 채점 완료. 성공: {}, 실패: {}", successCount, failureCount); + + // 모든 섹션이 실패한 경우 예외 발생 + if (successCount == 0) { + log.error("모든 섹션 채점이 실패했습니다. 실패 상세: {}", + results.stream() + .map(r -> String.format("[%s: %s]", r.sectionType(), r.errorMessage())) + .collect(Collectors.joining(", "))); + throw new starlight.domain.aireport.exception.AiReportException( + starlight.domain.aireport.exception.AiReportErrorType.AI_GRADING_FAILED); + } + // 슈퍼바이저가 장단점 생성 - List strengths = - supervisor.generateStrengths(content, results); - List weaknesses = - supervisor.generateWeaknesses(content, results); - + log.debug("슈퍼바이저 장단점 생성 시작"); + List strengths = supervisor.generateStrengths(fullContent, results); + List weaknesses = supervisor.generateWeaknesses(fullContent, results); + log.debug("슈퍼바이저 장단점 생성 완료. 강점: {}, 약점: {}", strengths.size(), weaknesses.size()); + // 결과 통합 - return assembleReportResponse(results, strengths, weaknesses); + AiReportResult finalResult = assembleReportResponse(results, strengths, weaknesses); + log.info("섹션별 채점 최종 완료. 총점: {}, 문제인식={}, 실현가능성={}, 성장전략={}, 팀역량={}", + finalResult.totalScore(), + finalResult.problemRecognitionScore(), + finalResult.feasibilityScore(), + finalResult.growthStrategyScore(), + finalResult.teamCompetenceScore()); + + return finalResult; } - + private AiReportResult assembleReportResponse( - List results, - List strengths, - List weaknesses - ) { + List results, + List strengths, + List weaknesses) { Integer problemRecognitionScore = extractScore( - results, - SectionType.PROBLEM_RECOGNITION - ); + results, + SectionType.PROBLEM_RECOGNITION); Integer feasibilityScore = extractScore(results, SectionType.FEASIBILITY); Integer growthStrategyScore = extractScore(results, SectionType.GROWTH_STRATEGY); Integer teamCompetenceScore = extractScore(results, SectionType.TEAM_COMPETENCE); - - List sectionScores = - results.stream() + + List sectionScores = results.stream() .filter(SectionGradingResult::success) .map(SectionGradingResult::sectionScore) .filter(Objects::nonNull) .collect(Collectors.toList()); - + // 기존 AiReportResponse.fromGradingResult 사용 (구조 동일) return AiReportResult.fromGradingResult( - problemRecognitionScore, - feasibilityScore, - growthStrategyScore, - teamCompetenceScore, - sectionScores, - strengths, - weaknesses - ); + problemRecognitionScore, + feasibilityScore, + growthStrategyScore, + teamCompetenceScore, + sectionScores, + strengths, + weaknesses); } - + private Integer extractScore(List results, SectionType type) { return results.stream() - .filter(r -> r.sectionType() == type) - .findFirst() - .map(SectionGradingResult::score) - .orElse(0); + .filter(r -> r.sectionType() == type) + .findFirst() + .map(SectionGradingResult::score) + .orElse(0); } } diff --git a/src/main/java/starlight/adapter/aireport/report/agent/FullReportGradeAgent.java b/src/main/java/starlight/adapter/aireport/report/agent/FullReportGradeAgent.java index 9fbde99..134ce93 100644 --- a/src/main/java/starlight/adapter/aireport/report/agent/FullReportGradeAgent.java +++ b/src/main/java/starlight/adapter/aireport/report/agent/FullReportGradeAgent.java @@ -1,4 +1,8 @@ -package starlight.adapter.aireport.report.agent.impl; +package starlight.adapter.aireport.report.agent; + +import starlight.application.aireport.provided.dto.AiReportResult; public interface FullReportGradeAgent { + + AiReportResult gradeFullReport(String content); } diff --git a/src/main/java/starlight/adapter/aireport/report/agent/SectionGradeAgent.java b/src/main/java/starlight/adapter/aireport/report/agent/SectionGradeAgent.java index 4b00638..1b3b6a2 100644 --- a/src/main/java/starlight/adapter/aireport/report/agent/SectionGradeAgent.java +++ b/src/main/java/starlight/adapter/aireport/report/agent/SectionGradeAgent.java @@ -1,15 +1,16 @@ -package starlight.adapter.aireport.reportgrader.agent; +package starlight.adapter.aireport.report.agent; -import starlight.adapter.aireport.reportgrader.dto.SectionGradingResult; +import starlight.adapter.aireport.report.dto.SectionGradingResult; import starlight.shared.enumerate.SectionType; public interface SectionGradeAgent { + SectionType getSectionType(); + SectionGradingResult gradeSection(String sectionContent); /** * SectionType의 tag를 기반으로 filter expression 생성 - * SubSectionType의 tag는 사용하지 않음 */ default String buildFilterExpression() { SectionType sectionType = getSectionType(); @@ -18,8 +19,7 @@ default String buildFilterExpression() { if (tag == null || tag.isBlank()) { return null; } - - // 단순히 "tag == 'problem_recognition'" 형식으로 반환 + return "tag == '" + tag + "'"; } } 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 d3e2153..9ca8ab7 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 @@ -8,39 +8,58 @@ import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.stereotype.Component; +import starlight.adapter.aireport.report.agent.FullReportGradeAgent; import starlight.adapter.aireport.report.provider.SpringAiAdvisorProvider; import starlight.adapter.aireport.report.provider.ReportPromptProvider; +import starlight.adapter.aireport.report.util.AiReportResponseParser; +import starlight.application.aireport.provided.dto.AiReportResult; +import starlight.domain.aireport.exception.AiReportErrorType; +import starlight.domain.aireport.exception.AiReportException; @Slf4j @Component @RequiredArgsConstructor -public class SpringAiGenerator { - - private final ChatClient.Builder chatClientBuilder; - private final ReportPromptProvider reportPromptProvider; - private final SpringAiAdvisorProvider advisorProvider; - - /** - * 전체 프롬프트를 사용하여 LLM에 리포트 채점을 요청하고 응답을 반환 - * @param content PDF에서 추출한 텍스트 또는 전체 사업계획서 내용 - * @return LLM 응답 문자열 (JSON 형식) - */ - public String generateReport(String content) { - Prompt prompt = reportPromptProvider.createReportGradingPrompt(content); - - ChatClient chatClient = chatClientBuilder.build(); - QuestionAnswerAdvisor qaAdvisor = advisorProvider - .getQuestionAnswerAdvisor(0.6, 3, null); - SimpleLoggerAdvisor slAdvisor = advisorProvider.getSimpleLoggerAdvisor(); - - return chatClient - .prompt(prompt) - .options(ChatOptions.builder() - .temperature(0.0) - .topP(0.1) - .build()) - .advisors(qaAdvisor, slAdvisor) - .call() - .content(); - } +public class SpringAiFullReportGradeAgent implements FullReportGradeAgent { + + private final ChatClient.Builder chatClientBuilder; + private final ReportPromptProvider reportPromptProvider; + private final SpringAiAdvisorProvider advisorProvider; + private final AiReportResponseParser responseParser; + + @Override + public AiReportResult gradeFullReport(String content) { + if (content == null || content.trim().isEmpty()) { + throw new AiReportException(AiReportErrorType.AI_GRADING_FAILED); + } + + try { + Prompt prompt = reportPromptProvider.createReportGradingPrompt(content); + + ChatClient chatClient = chatClientBuilder.build(); + QuestionAnswerAdvisor qaAdvisor = advisorProvider + .getQuestionAnswerAdvisor(0.6, 3, null); + SimpleLoggerAdvisor slAdvisor = advisorProvider.getSimpleLoggerAdvisor(); + + String llmResponse = chatClient + .prompt(prompt) + .options(ChatOptions.builder() + .temperature(0.0) + .topP(0.1) + .build()) + .advisors(qaAdvisor, slAdvisor) + .call() + .content(); + + if (llmResponse == null || llmResponse.trim().isEmpty()) { + throw new AiReportException(AiReportErrorType.AI_GRADING_FAILED); + } + + return responseParser.parse(llmResponse); + + } catch (AiReportException e) { + throw e; + } catch (Exception e) { + throw new AiReportException(AiReportErrorType.AI_GRADING_FAILED); + } + } } 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 69b0dd2..860889f 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 @@ -1,4 +1,4 @@ -package starlight.adapter.aireport.reportgrader.agent.impl; +package starlight.adapter.aireport.report.agent.impl; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -7,12 +7,12 @@ import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor; import org.springframework.ai.chat.prompt.ChatOptions; import org.springframework.ai.chat.prompt.Prompt; -import starlight.adapter.aireport.reportgrader.agent.SectionGradeAgent; -import starlight.adapter.aireport.reportgrader.circuitbreaker.SectionGradingCircuitBreaker; -import starlight.adapter.aireport.reportgrader.dto.SectionGradingResult; -import starlight.adapter.aireport.reportgrader.provider.SpringAiAdvisorProvider; -import starlight.adapter.aireport.reportgrader.provider.ReportPromptProvider; -import starlight.adapter.aireport.reportgrader.util.AiReportResponseParser; +import starlight.adapter.aireport.report.agent.SectionGradeAgent; +import starlight.adapter.aireport.report.circuitbreaker.SectionGradingCircuitBreaker; +import starlight.adapter.aireport.report.dto.SectionGradingResult; +import starlight.adapter.aireport.report.provider.SpringAiAdvisorProvider; +import starlight.adapter.aireport.report.provider.ReportPromptProvider; +import starlight.adapter.aireport.report.util.AiReportResponseParser; import starlight.application.aireport.provided.dto.AiReportResult; import starlight.shared.enumerate.SectionType; @@ -85,16 +85,17 @@ public SectionGradingResult gradeSection(String sectionContent) { private SectionGradingResult parseSectionResult(String llmResponse) { try { - AiReportResult fullResponse = responseParser.parse(llmResponse); + // 섹션별 응답 파싱 메소드 사용 + AiReportResult sectionResponse = responseParser.parseSectionResponse(llmResponse); // SectionType의 score 추출 메서드 사용 - Integer score = getSectionType().extractScore(fullResponse); + Integer score = getSectionType().extractScore(sectionResponse); // sectionScores에서 해당 섹션 찾기 String sectionTypeString = getSectionType().getSectionTypeString(); AiReportResult.SectionScoreDetailResponse sectionScore = null; if (sectionTypeString != null) { - sectionScore = fullResponse.sectionScores().stream() + sectionScore = sectionResponse.sectionScores().stream() .filter(ss -> sectionTypeString.equals(ss.sectionType())) .findFirst() .orElse(null); diff --git a/src/main/java/starlight/adapter/aireport/report/circuitbreaker/SectionGradingCircuitBreaker.java b/src/main/java/starlight/adapter/aireport/report/circuitbreaker/SectionGradingCircuitBreaker.java index 5221a91..1058a1b 100644 --- a/src/main/java/starlight/adapter/aireport/report/circuitbreaker/SectionGradingCircuitBreaker.java +++ b/src/main/java/starlight/adapter/aireport/report/circuitbreaker/SectionGradingCircuitBreaker.java @@ -1,4 +1,4 @@ -package starlight.adapter.aireport.reportgrader.circuitbreaker; +package starlight.adapter.aireport.report.circuitbreaker; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; diff --git a/src/main/java/starlight/adapter/aireport/report/config/SectionAdvisorConfig.java b/src/main/java/starlight/adapter/aireport/report/config/SectionAdvisorConfig.java index 3c80fb1..c39656d 100644 --- a/src/main/java/starlight/adapter/aireport/report/config/SectionAdvisorConfig.java +++ b/src/main/java/starlight/adapter/aireport/report/config/SectionAdvisorConfig.java @@ -1,15 +1,15 @@ -package starlight.adapter.aireport.reportgrader.config; +package starlight.adapter.aireport.report.config; import lombok.RequiredArgsConstructor; import org.springframework.ai.chat.client.ChatClient; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import starlight.adapter.aireport.reportgrader.agent.SectionGradeAgent; -import starlight.adapter.aireport.reportgrader.agent.impl.SpringAiSectionGradeAgent; -import starlight.adapter.aireport.reportgrader.circuitbreaker.SectionGradingCircuitBreaker; -import starlight.adapter.aireport.reportgrader.provider.SpringAiAdvisorProvider; -import starlight.adapter.aireport.reportgrader.provider.ReportPromptProvider; -import starlight.adapter.aireport.reportgrader.util.AiReportResponseParser; +import starlight.adapter.aireport.report.agent.SectionGradeAgent; +import starlight.adapter.aireport.report.agent.impl.SpringAiSectionGradeAgent; +import starlight.adapter.aireport.report.circuitbreaker.SectionGradingCircuitBreaker; +import starlight.adapter.aireport.report.provider.SpringAiAdvisorProvider; +import starlight.adapter.aireport.report.provider.ReportPromptProvider; +import starlight.adapter.aireport.report.util.AiReportResponseParser; import starlight.shared.enumerate.SectionType; import java.util.Arrays; @@ -34,7 +34,7 @@ public List sectionAdvisors() { .map(sectionType -> new SpringAiSectionGradeAgent( sectionType, chatClientBuilder, - reportPromptProvider, + reportPromptProvider, advisorProvider, responseParser, circuitBreaker diff --git a/src/main/java/starlight/adapter/aireport/report/dto/SectionGradingResult.java b/src/main/java/starlight/adapter/aireport/report/dto/SectionGradingResult.java index 3ee3b3d..117266e 100644 --- a/src/main/java/starlight/adapter/aireport/report/dto/SectionGradingResult.java +++ b/src/main/java/starlight/adapter/aireport/report/dto/SectionGradingResult.java @@ -1,4 +1,4 @@ -package starlight.adapter.aireport.reportgrader.dto; +package starlight.adapter.aireport.report.dto; import starlight.application.aireport.provided.dto.AiReportResult.SectionScoreDetailResponse; import starlight.shared.enumerate.SectionType; diff --git a/src/main/java/starlight/adapter/aireport/report/provider/ReportPromptProvider.java b/src/main/java/starlight/adapter/aireport/report/provider/ReportPromptProvider.java index 24fd839..3c590b7 100644 --- a/src/main/java/starlight/adapter/aireport/report/provider/ReportPromptProvider.java +++ b/src/main/java/starlight/adapter/aireport/report/provider/ReportPromptProvider.java @@ -1,18 +1,14 @@ -package starlight.adapter.aireport.reportgrader.provider; +package starlight.adapter.aireport.report.provider; import org.springframework.ai.chat.prompt.Prompt; import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.SystemMessage; import org.springframework.ai.chat.messages.UserMessage; -import org.springframework.ai.chat.prompt.PromptTemplate; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import starlight.domain.businessplan.enumerate.SubSectionType; import starlight.shared.enumerate.SectionType; -import java.util.HashMap; import java.util.List; -import java.util.Map; @Component public class ReportPromptProvider { @@ -38,20 +34,11 @@ public class ReportPromptProvider { @Value("${prompt.report.section.team_competence.system}") private String teamCompetenceSystemPrompt; - @Value("${prompt.checklist.grading.system}") - private String checklistGradingSystemPrompt; - - @Value("${prompt.checklist.grading.user.template}") - private String checklistGradingUserPromptTemplate; - /** * 리포트 채점용 Prompt 객체 생성 - * @deprecated 전체 리포트를 한 번에 채점하는 방식은 더 이상 권장되지 않습니다. - * 대신 {@link #createSectionGradingPrompt(SectionType, String)}를 사용하여 섹션별로 채점하는 방식을 사용하세요. */ - @Deprecated public Prompt createReportGradingPrompt(String businessPlanContent) { - Message systemMessage = new SystemMessage(getReportGradingSystemPrompt()); + Message systemMessage = new SystemMessage(reportGradingSystemPrompt); Message userMessage = new UserMessage(businessPlanContent); // 사업계획서 내용만 직접 전달 return new Prompt(List.of(systemMessage, userMessage)); } @@ -67,27 +54,6 @@ public Prompt createSectionGradingPrompt(SectionType sectionType, String section return new Prompt(List.of(systemMessage, userMessage)); } - /** - * 체크리스트 채점용 Prompt 객체 생성 - */ - public Prompt createChecklistGradingPrompt( - SubSectionType subSectionType, - String content, - List criteria, - List detailedCriteria) { - String userPrompt = buildChecklistGradingUserPrompt(subSectionType, content, criteria, detailedCriteria); - Message systemMessage = new SystemMessage(checklistGradingSystemPrompt); - Message userMessage = new UserMessage(userPrompt); - return new Prompt(List.of(systemMessage, userMessage)); - } - - /** - * 리포트 채점용 시스템 프롬프트 - */ - private String getReportGradingSystemPrompt() { - return reportGradingSystemPrompt; - } - /** * 섹션별 채점용 시스템 프롬프트 * 공통 프롬프트와 섹션별 프롬프트를 합쳐서 반환 @@ -100,34 +66,8 @@ private String getSectionGradingSystemPrompt(SectionType sectionType) { case TEAM_COMPETENCE -> teamCompetenceSystemPrompt; default -> ""; // 기본값 }; - + // 공통 프롬프트와 섹션별 프롬프트를 합침 return sectionDefaultSystemPrompt + "\n\n" + sectionSpecificPrompt; } - - /** - * 체크리스트 채점용 사용자 프롬프트 생성 - */ - private String buildChecklistGradingUserPrompt( - SubSectionType subSectionType, - String content, - List criteria, - List detailedCriteria) { - // 체크리스트 상세 기준 포맷팅 - StringBuilder criteriaBuilder = new StringBuilder(); - for (int i = 0; i < criteria.size() && i < detailedCriteria.size(); i++) { - criteriaBuilder.append(i + 1).append(") ").append(criteria.get(i)).append("\n"); - criteriaBuilder.append(detailedCriteria.get(i)).append("\n\n"); - } - String formattedCriteria = criteriaBuilder.toString().trim(); - - Map variables = new HashMap<>(); - variables.put("subsectionType", subSectionType.getDescription()); - variables.put("checklistCriteria", formattedCriteria); - variables.put("input", content); - variables.put("requestLength", criteria.size()); - - PromptTemplate promptTemplate = new PromptTemplate(checklistGradingUserPromptTemplate); - return promptTemplate.render(variables); - } } 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 ccd9e20..16c742a 100644 --- a/src/main/java/starlight/adapter/aireport/report/provider/SpringAiAdvisorProvider.java +++ b/src/main/java/starlight/adapter/aireport/report/provider/SpringAiAdvisorProvider.java @@ -1,4 +1,4 @@ -package starlight.adapter.aireport.reportgrader.provider; +package starlight.adapter.aireport.report.provider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/starlight/adapter/aireport/report/supervisor/ReportSupervisor.java b/src/main/java/starlight/adapter/aireport/report/supervisor/ReportSupervisor.java index 4afd8aa..2d75b9c 100644 --- a/src/main/java/starlight/adapter/aireport/report/supervisor/ReportSupervisor.java +++ b/src/main/java/starlight/adapter/aireport/report/supervisor/ReportSupervisor.java @@ -1,4 +1,4 @@ -package starlight.adapter.aireport.reportgrader.supervisor; +package starlight.adapter.aireport.report.supervisor; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -10,8 +10,8 @@ import org.springframework.ai.chat.messages.UserMessage; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; -import starlight.adapter.aireport.reportgrader.dto.SectionGradingResult; -import starlight.adapter.aireport.reportgrader.util.AiReportResponseParser; +import starlight.adapter.aireport.report.dto.SectionGradingResult; +import starlight.adapter.aireport.report.util.AiReportResponseParser; import starlight.application.aireport.provided.dto.AiReportResult; import java.util.HashMap; @@ -61,8 +61,8 @@ private List generateStrengthWeakness( new UserMessage(prompt) ))) .options(ChatOptions.builder() - .temperature(0.1) - .topP(0.2) + .temperature(0.0) + .topP(0.1) .build()) .call() .content(); diff --git a/src/main/java/starlight/adapter/aireport/report/util/AiReportResponseParser.java b/src/main/java/starlight/adapter/aireport/report/util/AiReportResponseParser.java index 5ec3db8..0933d3c 100644 --- a/src/main/java/starlight/adapter/aireport/report/util/AiReportResponseParser.java +++ b/src/main/java/starlight/adapter/aireport/report/util/AiReportResponseParser.java @@ -1,4 +1,4 @@ -package starlight.adapter.aireport.reportgrader.util; +package starlight.adapter.aireport.report.util; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -89,7 +89,8 @@ public AiReportResult toResponse(AiReport aiReport) { AiReportResult baseResponse = parseFromJsonNode(jsonNode); // totalScore 계산 - Integer totalScore = (baseResponse.problemRecognitionScore() != null ? baseResponse.problemRecognitionScore() : 0) + + 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); @@ -105,8 +106,7 @@ public AiReportResult toResponse(AiReport aiReport) { baseResponse.teamCompetenceScore(), baseResponse.sectionScores(), baseResponse.strengths(), - baseResponse.weaknesses() - ); + baseResponse.weaknesses()); } /** @@ -114,52 +114,52 @@ public AiReportResult toResponse(AiReport aiReport) { */ 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()); + (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로 파싱 - * 파싱 실패 시 예외를 던집니다. + * LLM 응답 문자열을 AiReportResponse로 파싱 (전체 리포트용) + * 4개의 전체 점수 필드를 모두 요구 */ public AiReportResult parse(String llmResponse) { log.debug("Raw LLM response: {}", llmResponse); - + // 1. 기본 검증 if (llmResponse == null || llmResponse.trim().isEmpty()) { log.error("LLM response is null or empty"); throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); } - + try { // 2. JSON 문자열 정리 String cleanedJson = cleanJsonResponse(llmResponse); log.debug("Cleaned JSON: {}", cleanedJson); - + // 3. JSON 파싱 시도 JsonNode jsonNode = objectMapper.readTree(cleanedJson); - - // 4. 필수 필드 존재 여부 확인 + + // 4. 필수 필드 존재 여부 확인 (전체 리포트는 4개 필드 모두 필요) if (!jsonNode.has("problemRecognitionScore") || - !jsonNode.has("feasibilityScore") || - !jsonNode.has("growthStrategyScore") || - !jsonNode.has("teamCompetenceScore")) { + !jsonNode.has("feasibilityScore") || + !jsonNode.has("growthStrategyScore") || + !jsonNode.has("teamCompetenceScore")) { throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); } - + // 5. 파싱 시도 AiReportResult response = parseFromJsonNode(jsonNode); - + // 6. 파싱된 값이 기본값인지 확인 if (isDefaultResponse(response)) { log.error("Parsed response is default (all zeros), likely parsing failure"); throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); } - + return response; } catch (Exception e) { log.error("Failed to parse LLM response. Response: {}", llmResponse, e); @@ -167,6 +167,76 @@ public AiReportResult parse(String llmResponse) { } } + /** + * 섹션별 채점 응답을 파싱 (섹션별 Agent용) + * 하나의 섹션 점수만 포함하는 응답을 처리합니다. + * 예: {"feasibilityScore": 0, "sectionScores": [...]} + */ + public AiReportResult parseSectionResponse(String llmResponse) { + log.debug("Raw section LLM response: {}", llmResponse); + + // 1. 기본 검증 + if (llmResponse == null || llmResponse.trim().isEmpty()) { + log.error("Section LLM response is null or empty"); + throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } + + try { + // 2. JSON 문자열 정리 + String cleanedJson = cleanJsonResponse(llmResponse); + log.debug("Cleaned section JSON: {}", cleanedJson); + + // 3. JSON 파싱 시도 + JsonNode jsonNode = objectMapper.readTree(cleanedJson); + + // 4. 섹션별 응답은 하나의 점수 필드만 있으면 됨 + // 어떤 섹션 점수 필드가 있는지 확인 + 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(0); + } + if (jsonNode.has("feasibilityScore") && !jsonNode.path("feasibilityScore").isNull()) { + feasibilityScore = jsonNode.path("feasibilityScore").asInt(0); + } + if (jsonNode.has("growthStrategyScore") && !jsonNode.path("growthStrategyScore").isNull()) { + growthStrategyScore = jsonNode.path("growthStrategyScore").asInt(0); + } + if (jsonNode.has("teamCompetenceScore") && !jsonNode.path("teamCompetenceScore").isNull()) { + teamCompetenceScore = jsonNode.path("teamCompetenceScore").asInt(0); + } + + // 최소 하나의 점수 필드는 있어야 함 + if (problemRecognitionScore == null && feasibilityScore == null + && growthStrategyScore == null && teamCompetenceScore == null) { + log.error("No section score field found in response"); + throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } + + // 5. 섹션별 응답 파싱 (없는 필드는 null로 설정) + List sectionScores = parseSectionScores( + jsonNode.path("sectionScores")); + + // strengths와 weaknesses는 섹션별 응답에는 없음 + return AiReportResult.fromGradingResult( + problemRecognitionScore != null ? problemRecognitionScore : 0, + feasibilityScore != null ? feasibilityScore : 0, + growthStrategyScore != null ? growthStrategyScore : 0, + teamCompetenceScore != null ? teamCompetenceScore : 0, + sectionScores, + List.of(), // strengths는 빈 리스트 + List.of() // weaknesses는 빈 리스트 + ); + + } catch (Exception e) { + log.error("Failed to parse section LLM response. Response: {}", llmResponse, e); + throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } + } + /** * JSON 응답 문자열 정리 및 복구 */ @@ -174,9 +244,9 @@ private String cleanJsonResponse(String json) { if (json == null || json.trim().isEmpty()) { return "{}"; } - + String cleaned = json.trim(); - + // 1. JSON 코드 블록 마커 제거 (```json ... ``` 또는 ``` ... ```) if (cleaned.startsWith("```json")) { cleaned = cleaned.substring(7); @@ -187,7 +257,7 @@ private String cleanJsonResponse(String json) { cleaned = cleaned.substring(0, cleaned.length() - 3); } cleaned = cleaned.trim(); - + // 2. "text" 필드에서 JSON 추출 (더 강력한 추출) // 정규식으로 "text" 필드 추출 시도 if (cleaned.contains("\"text\"") || cleaned.contains("'text'")) { @@ -202,16 +272,15 @@ private String cleanJsonResponse(String json) { try { // "text" : "..." 패턴 찾기 java.util.regex.Pattern pattern = java.util.regex.Pattern.compile( - "\"text\"\\s*:\\s*\"(.*)\"", - java.util.regex.Pattern.DOTALL - ); + "\"text\"\\s*:\\s*\"(.*)\"", + java.util.regex.Pattern.DOTALL); java.util.regex.Matcher matcher = pattern.matcher(cleaned); if (matcher.find()) { String extracted = matcher.group(1); // 이스케이프된 문자 처리 extracted = extracted.replace("\\n", "\n") - .replace("\\\"", "\"") - .replace("\\\\", "\\"); + .replace("\\\"", "\"") + .replace("\\\\", "\\"); cleaned = extracted; log.debug("Extracted text field using regex"); } @@ -224,9 +293,88 @@ private String cleanJsonResponse(String json) { // 3. 잘못된 따옴표 패턴 수정 (공백이 포함된 필드명) cleaned = cleaned.replaceAll("\"\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s+\"", "\"$1\""); + // 4. 불완전한 JSON 복구 (닫히지 않은 배열/객체 감지 및 복구) + cleaned = repairIncompleteJson(cleaned); + return cleaned; } + /** + * 불완전한 JSON을 복구 (닫히지 않은 배열/객체 감지 및 복구) + */ + private String repairIncompleteJson(String json) { + if (json == null || json.trim().isEmpty()) { + return json; + } + + // 괄호 균형 확인 + int openBraces = 0; // { + int closeBraces = 0; // } + int openBrackets = 0; // [ + int closeBrackets = 0; // ] + + boolean inString = false; + boolean escaped = false; + + for (int i = 0; i < json.length(); i++) { + char c = json.charAt(i); + + if (escaped) { + escaped = false; + continue; + } + + if (c == '\\') { + escaped = true; + continue; + } + + if (c == '"') { + inString = !inString; + continue; + } + + if (inString) { + continue; + } + + switch (c) { + case '{': + openBraces++; + break; + case '}': + closeBraces++; + break; + case '[': + openBrackets++; + break; + case ']': + closeBrackets++; + break; + } + } + + // 닫히지 않은 괄호 추가 + StringBuilder repaired = new StringBuilder(json); + int missingCloseBrackets = openBrackets - closeBrackets; + int missingCloseBraces = openBraces - closeBraces; + + // 배열을 먼저 닫고, 그 다음 객체를 닫음 + for (int i = 0; i < missingCloseBrackets; i++) { + repaired.append(']'); + } + for (int i = 0; i < missingCloseBraces; i++) { + repaired.append('}'); + } + + if (missingCloseBrackets > 0 || missingCloseBraces > 0) { + log.warn("불완전한 JSON 감지 및 복구. 누락된 괄호: ] {}개, }} {}개", + missingCloseBrackets, missingCloseBraces); + } + + return repaired.toString(); + } + /** * JsonNode를 파싱하여 AiReportResponse로 변환 */ @@ -263,7 +411,7 @@ public List parseStrengthWeakness(String llmRes try { String cleanedJson = cleanJsonResponse(llmResponse); JsonNode jsonNode = objectMapper.readTree(cleanedJson); - + JsonNode targetNode = jsonNode.path(type); return parseStrengthWeaknessList(targetNode); } catch (Exception e) { @@ -298,25 +446,26 @@ private List parseSectionScores(JsonN try { String sectionType = sectionScoreNode.path("sectionType").asText(""); String gradingListScores = sectionScoreNode.path("gradingListScores").asText("[]"); - + // gradingListScores가 유효한 JSON 문자열인지 검증 if (!gradingListScores.equals("[]")) { try { // JSON 배열 형식인지 확인 if (!gradingListScores.trim().startsWith("[")) { - log.warn("Invalid gradingListScores format for sectionType: {}, using default", sectionType); + log.warn("Invalid gradingListScores format for sectionType: {}, using default", + sectionType); gradingListScores = "[]"; } else { // JSON 파싱 가능 여부 확인 objectMapper.readTree(gradingListScores); } } catch (Exception e) { - log.warn("Failed to parse gradingListScores for sectionType: {}, using default. Value: {}", + log.warn("Failed to parse gradingListScores for sectionType: {}, using default. Value: {}", sectionType, gradingListScores); gradingListScores = "[]"; } } - + list.add(new AiReportResult.SectionScoreDetailResponse(sectionType, gradingListScores)); } catch (Exception e) { log.warn("Failed to parse sectionScore item, skipping: {}", e.getMessage()); From 649671cfa3a518a4cdd9428b9d377efeb809d6fa Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Mon, 5 Jan 2026 22:41:41 +0900 Subject: [PATCH 05/74] =?UTF-8?q?[SRLT-124]=20Feat:=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=B1=84=EC=A0=90=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=EC=9D=84=20=EB=B3=84=EB=8F=84=20=ED=8C=A8=ED=82=A4?= =?UTF-8?q?=EC=A7=80=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../checklist/SpringAiChecklistGrader.java | 8 +-- .../agent/SpringAiChecklistAgent.java | 4 +- .../provider/ChecklistPromptProvider.java | 55 ++++++++++++++++++- 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/src/main/java/starlight/adapter/businessplan/checklist/SpringAiChecklistGrader.java b/src/main/java/starlight/adapter/businessplan/checklist/SpringAiChecklistGrader.java index a4e58c5..33b8b1f 100644 --- a/src/main/java/starlight/adapter/businessplan/checklist/SpringAiChecklistGrader.java +++ b/src/main/java/starlight/adapter/businessplan/checklist/SpringAiChecklistGrader.java @@ -1,22 +1,22 @@ -package starlight.adapter.businessplan.checklistgrader; +package starlight.adapter.businessplan.checklist; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import starlight.adapter.businessplan.checklistgrader.generator.SpringAiChecklistGenerator; +import starlight.adapter.businessplan.checklist.agent.SpringAiChecklistAgent; import starlight.application.businessplan.required.ChecklistGraderPort; import starlight.domain.businessplan.enumerate.SubSectionType; import java.util.List; import java.util.ArrayList; -import starlight.adapter.aireport.reportgrader.provider.ChecklistPromptProvider; +import starlight.adapter.businessplan.checklist.provider.ChecklistPromptProvider; @Slf4j @Service @RequiredArgsConstructor public class SpringAiChecklistGrader implements ChecklistGraderPort { - private final SpringAiChecklistGenerator generator; + private final SpringAiChecklistAgent generator; private final ChecklistPromptProvider checklistCatalog; @Override diff --git a/src/main/java/starlight/adapter/businessplan/checklist/agent/SpringAiChecklistAgent.java b/src/main/java/starlight/adapter/businessplan/checklist/agent/SpringAiChecklistAgent.java index 7a17837..1e6c416 100644 --- a/src/main/java/starlight/adapter/businessplan/checklist/agent/SpringAiChecklistAgent.java +++ b/src/main/java/starlight/adapter/businessplan/checklist/agent/SpringAiChecklistAgent.java @@ -1,4 +1,4 @@ -package starlight.adapter.businessplan.checklist.generator; +package starlight.adapter.businessplan.checklist.agent; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -18,7 +18,7 @@ @Slf4j @Component @RequiredArgsConstructor -public class SpringAiChecklistGenerator { +public class SpringAiChecklistAgent { private final ChatClient.Builder chatClientBuilder; private final ChecklistPromptProvider checklistPromptProvider; diff --git a/src/main/java/starlight/adapter/businessplan/checklist/provider/ChecklistPromptProvider.java b/src/main/java/starlight/adapter/businessplan/checklist/provider/ChecklistPromptProvider.java index bb85cf2..42bf7a3 100644 --- a/src/main/java/starlight/adapter/businessplan/checklist/provider/ChecklistPromptProvider.java +++ b/src/main/java/starlight/adapter/businessplan/checklist/provider/ChecklistPromptProvider.java @@ -1,11 +1,18 @@ -package starlight.adapter.aireport.reportgrader.provider; +package starlight.adapter.businessplan.checklist.provider; import lombok.Getter; import lombok.Setter; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.chat.prompt.PromptTemplate; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; import starlight.domain.businessplan.enumerate.SubSectionType; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -18,6 +25,12 @@ public class ChecklistPromptProvider { private Map catalog; + @Value("${prompt.checklist.grading.system}") + private String checklistGradingSystemPrompt; + + @Value("${prompt.checklist.grading.user.template}") + private String checklistGradingUserPromptTemplate; + @Getter @Setter public static class CatalogSection { @@ -62,4 +75,44 @@ public List getDetailedCriteriaBySubSectionType(SubSectionType subSectio .filter(d -> d != null && !d.isEmpty()) .collect(Collectors.toList()); } + + /** + * 체크리스트 채점용 Prompt 객체 생성 + */ + public Prompt createChecklistGradingPrompt( + SubSectionType subSectionType, + String content, + List criteria, + List detailedCriteria) { + String userPrompt = buildChecklistGradingUserPrompt(subSectionType, content, criteria, detailedCriteria); + Message systemMessage = new SystemMessage(checklistGradingSystemPrompt); + Message userMessage = new UserMessage(userPrompt); + return new Prompt(List.of(systemMessage, userMessage)); + } + + /** + * 체크리스트 채점용 사용자 프롬프트 생성 + */ + private String buildChecklistGradingUserPrompt( + SubSectionType subSectionType, + String content, + List criteria, + List detailedCriteria) { + // 체크리스트 상세 기준 포맷팅 + StringBuilder criteriaBuilder = new StringBuilder(); + for (int i = 0; i < criteria.size() && i < detailedCriteria.size(); i++) { + criteriaBuilder.append(i + 1).append(") ").append(criteria.get(i)).append("\n"); + criteriaBuilder.append(detailedCriteria.get(i)).append("\n\n"); + } + String formattedCriteria = criteriaBuilder.toString().trim(); + + Map variables = new HashMap<>(); + variables.put("subsectionType", subSectionType.getDescription()); + variables.put("checklistCriteria", formattedCriteria); + variables.put("input", content); + variables.put("requestLength", criteria.size()); + + PromptTemplate promptTemplate = new PromptTemplate(checklistGradingUserPromptTemplate); + return promptTemplate.render(variables); + } } From 96f737231e5743e2000f3e26341df4dc21e03bfc Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Mon, 5 Jan 2026 22:41:45 +0900 Subject: [PATCH 06/74] =?UTF-8?q?[SRLT-124]=20Refactor:=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20adapter.ai=20=ED=8C=A8=ED=82=A4=EC=A7=80=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20=EC=83=88=EB=A1=9C=EC=9A=B4=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/ai/OpenAiChecklistGrader.java | 45 --- .../adapter/ai/OpenAiReportGrader.java | 29 -- .../adapter/ai/infra/AdvisorProvider.java | 39 --- .../adapter/ai/infra/OpenAiGenerator.java | 80 ----- .../adapter/ai/infra/PromptProvider.java | 96 ------ .../ai/util/AiReportResponseParser.java | 314 ------------------ .../adapter/ai/util/ChecklistCatalog.java | 65 ---- .../persistence/ExpertApplicationJpaPort.java | 78 ----- .../aireport/provided/AiReportService.java | 11 - .../provided/dto/AiReportResponse.java | 67 ---- .../aireport/required/AiReportGrader.java | 7 - .../aireport/required/AiReportQuery.java | 11 - .../aireport/required/OcrProvider.java | 10 - .../required/PresignedUrlProvider.java | 10 - .../businessplan/BusinessPlanServiceImpl.java | 256 -------------- .../provided/BusinessPlanService.java | 38 --- .../provided/dto/BusinessPlanResponse.java | 93 ------ .../provided/dto/SubSectionResponse.java | 39 --- .../required/BusinessPlanQuery.java | 18 - .../required/ChecklistGrader.java | 20 -- .../businessplan/required/SpellChecker.java | 12 - .../infrastructure/provided/LlmGenerator.java | 12 - 22 files changed, 1350 deletions(-) delete mode 100644 src/main/java/starlight/adapter/ai/OpenAiChecklistGrader.java delete mode 100644 src/main/java/starlight/adapter/ai/OpenAiReportGrader.java delete mode 100644 src/main/java/starlight/adapter/ai/infra/AdvisorProvider.java delete mode 100644 src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java delete mode 100644 src/main/java/starlight/adapter/ai/infra/PromptProvider.java delete mode 100644 src/main/java/starlight/adapter/ai/util/AiReportResponseParser.java delete mode 100644 src/main/java/starlight/adapter/ai/util/ChecklistCatalog.java delete mode 100644 src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpaPort.java delete mode 100644 src/main/java/starlight/application/aireport/provided/AiReportService.java delete mode 100644 src/main/java/starlight/application/aireport/provided/dto/AiReportResponse.java delete mode 100644 src/main/java/starlight/application/aireport/required/AiReportGrader.java delete mode 100644 src/main/java/starlight/application/aireport/required/AiReportQuery.java delete mode 100644 src/main/java/starlight/application/aireport/required/OcrProvider.java delete mode 100644 src/main/java/starlight/application/aireport/required/PresignedUrlProvider.java delete mode 100644 src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java delete mode 100644 src/main/java/starlight/application/businessplan/provided/BusinessPlanService.java delete mode 100644 src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResponse.java delete mode 100644 src/main/java/starlight/application/businessplan/provided/dto/SubSectionResponse.java delete mode 100644 src/main/java/starlight/application/businessplan/required/BusinessPlanQuery.java delete mode 100644 src/main/java/starlight/application/businessplan/required/ChecklistGrader.java delete mode 100644 src/main/java/starlight/application/businessplan/required/SpellChecker.java delete mode 100644 src/main/java/starlight/application/infrastructure/provided/LlmGenerator.java diff --git a/src/main/java/starlight/adapter/ai/OpenAiChecklistGrader.java b/src/main/java/starlight/adapter/ai/OpenAiChecklistGrader.java deleted file mode 100644 index 6ea468e..0000000 --- a/src/main/java/starlight/adapter/ai/OpenAiChecklistGrader.java +++ /dev/null @@ -1,45 +0,0 @@ -package starlight.adapter.ai; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import starlight.adapter.ai.infra.OpenAiGenerator; -import starlight.application.businessplan.required.ChecklistGrader; -import starlight.domain.businessplan.enumerate.SubSectionType; - -import java.util.List; -import java.util.ArrayList; -import starlight.adapter.ai.util.ChecklistCatalog; - -@Slf4j -@Service -@RequiredArgsConstructor -public class OpenAiChecklistGrader implements ChecklistGrader { - - private final OpenAiGenerator generator; - private final ChecklistCatalog checklistCatalog; - - @Override - public List check( - SubSectionType subSectionType, - String content - ) { - // 1) 서브섹션별 체크리스트 기준 5개 확보 - List criteria = checklistCatalog.getCriteriaBySubSectionType(subSectionType); - List detailedCriteria = checklistCatalog.getDetailedCriteriaBySubSectionType(subSectionType); - - // 2) LLM 호출 → Boolean 배열 파싱 - List result = generator.generateChecklistArray(subSectionType, content, criteria, detailedCriteria); - - // 3) 보정: 항상 길이 5 보장 - return normalizeToFive(result); - } - - private List normalizeToFive(List in) { - List out = new ArrayList<>(5); - for (int i = 0; i < 5; i++) { - out.add(i < in.size() && in.get(i) != null ? in.get(i) : false); - } - return out; - } -} diff --git a/src/main/java/starlight/adapter/ai/OpenAiReportGrader.java b/src/main/java/starlight/adapter/ai/OpenAiReportGrader.java deleted file mode 100644 index 510d518..0000000 --- a/src/main/java/starlight/adapter/ai/OpenAiReportGrader.java +++ /dev/null @@ -1,29 +0,0 @@ -package starlight.adapter.ai; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import starlight.adapter.ai.infra.OpenAiGenerator; -import starlight.adapter.ai.util.AiReportResponseParser; -import starlight.application.aireport.provided.dto.AiReportResponse; -import starlight.application.aireport.required.AiReportGrader; - -/** - * AI 리포트 채점을 오케스트레이션하는 컴포넌트 - * 각 단계별 책임을 다른 컴포넌트에 위임하여 단일 책임 원칙을 준수 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class OpenAiReportGrader implements AiReportGrader { - - private final OpenAiGenerator chatClientGenerator; - private final AiReportResponseParser responseParser; - - @Override - public AiReportResponse gradeContent(String content){ - String llmResponse = chatClientGenerator.generateReport(content); - - return responseParser.parse(llmResponse); - } -} diff --git a/src/main/java/starlight/adapter/ai/infra/AdvisorProvider.java b/src/main/java/starlight/adapter/ai/infra/AdvisorProvider.java deleted file mode 100644 index 61e7348..0000000 --- a/src/main/java/starlight/adapter/ai/infra/AdvisorProvider.java +++ /dev/null @@ -1,39 +0,0 @@ -package starlight.adapter.ai.infra; - -import lombok.RequiredArgsConstructor; -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.vectorstore.SearchRequest; -import org.springframework.ai.vectorstore.VectorStore; -import org.springframework.core.Ordered; -import org.springframework.stereotype.Service; - -@Service -@Slf4j -@RequiredArgsConstructor -public class AdvisorProvider { - - private final VectorStore vectorStore; - - public QuestionAnswerAdvisor getQuestionAnswerAdvisor(double similarityThreshold, int topK, String filter){ - SearchRequest.Builder builder = SearchRequest.builder() - .similarityThreshold(similarityThreshold) - .topK(topK); - - if (filter != null && !filter.trim().isEmpty()) { - builder.filterExpression(filter); - } - - SearchRequest searchRequest = builder.build(); - - return QuestionAnswerAdvisor - .builder(vectorStore) - .searchRequest(searchRequest) - .build(); - } - - public SimpleLoggerAdvisor getSimpleLoggerAdvisor(){ - return new SimpleLoggerAdvisor(Ordered.LOWEST_PRECEDENCE-1); - } -} diff --git a/src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java b/src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java deleted file mode 100644 index c90ffb2..0000000 --- a/src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java +++ /dev/null @@ -1,80 +0,0 @@ -package starlight.adapter.ai.infra; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.ai.chat.client.ChatClient; -import org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor; -import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor; -import org.springframework.ai.chat.prompt.ChatOptions; -import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.stereotype.Component; -import starlight.application.infrastructure.provided.LlmGenerator; -import starlight.domain.businessplan.enumerate.SubSectionType; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.core.type.TypeReference; - -import java.util.List; - -@Slf4j -@Component -@RequiredArgsConstructor -public class OpenAiGenerator implements LlmGenerator { - - private final ChatClient.Builder chatClientBuilder; - private final PromptProvider promptProvider; - private final AdvisorProvider advisorProvider; - private final ObjectMapper objectMapper = new ObjectMapper(); - - @Override - public List generateChecklistArray( - SubSectionType subSectionType, - String content, - List criteria, - List detailedCriteria - ) { - Prompt prompt = promptProvider.createChecklistGradingPrompt( - subSectionType, content, criteria, detailedCriteria - ); - - ChatClient chatClient = chatClientBuilder.build(); - - SimpleLoggerAdvisor slAdvisor = advisorProvider.getSimpleLoggerAdvisor(); - - String output = chatClient - .prompt(prompt) - .options(ChatOptions.builder() - .temperature(0.1) - .topP(0.1) - .build()) - .advisors(slAdvisor) - .call() - .content(); - - try { - return objectMapper.readValue(output, new TypeReference>() { - }); - } catch (Exception e) { - return List.of(false, false, false, false, false); - } - } - - @Override - public String generateReport(String content) { - Prompt prompt = promptProvider.createReportGradingPrompt(content); - - ChatClient chatClient = chatClientBuilder.build(); - QuestionAnswerAdvisor qaAdvisor = advisorProvider - .getQuestionAnswerAdvisor(0.6, 3, null); - SimpleLoggerAdvisor slAdvisor = advisorProvider.getSimpleLoggerAdvisor(); - - return chatClient - .prompt(prompt) - .options(ChatOptions.builder() - .temperature(0.0) - .topP(0.1) - .build()) - .advisors(qaAdvisor, slAdvisor) - .call() - .content(); - } -} diff --git a/src/main/java/starlight/adapter/ai/infra/PromptProvider.java b/src/main/java/starlight/adapter/ai/infra/PromptProvider.java deleted file mode 100644 index 9b28336..0000000 --- a/src/main/java/starlight/adapter/ai/infra/PromptProvider.java +++ /dev/null @@ -1,96 +0,0 @@ -package starlight.adapter.ai.infra; - -import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.chat.messages.Message; -import org.springframework.ai.chat.messages.SystemMessage; -import org.springframework.ai.chat.messages.UserMessage; -import org.springframework.ai.chat.prompt.PromptTemplate; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import starlight.domain.businessplan.enumerate.SubSectionType; - -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -@Component -public class PromptProvider { - - @Value("${prompt.report.grading.system}") - private String reportGradingSystemPrompt; - - @Value("${prompt.report.grading.user}") - private String reportGradingUserPromptTemplate; - - @Value("${prompt.checklist.grading.system}") - private String checklistGradingSystemPrompt; - - @Value("${prompt.checklist.grading.user.template}") - private String checklistGradingUserPromptTemplate; - - /** - * 리포트 채점용 Prompt 객체 생성 - */ - public Prompt createReportGradingPrompt(String businessPlanContent) { - Message systemMessage = new SystemMessage(getReportGradingSystemPrompt()); - Message userMessage = new UserMessage(buildReportGradingUserPrompt(businessPlanContent)); - return new Prompt(List.of(systemMessage, userMessage)); - } - - /** - * 체크리스트 채점용 Prompt 객체 생성 - */ - public Prompt createChecklistGradingPrompt( - SubSectionType subSectionType, - String content, - List criteria, - List detailedCriteria - ) { - String userPrompt = buildChecklistGradingUserPrompt(subSectionType, content, criteria, detailedCriteria); - Message systemMessage = new SystemMessage(checklistGradingSystemPrompt); - Message userMessage = new UserMessage(userPrompt); - return new Prompt(List.of(systemMessage, userMessage)); - } - - /** - * 리포트 채점용 시스템 프롬프트 - */ - private String getReportGradingSystemPrompt() { - return reportGradingSystemPrompt; - } - - /** - * 리포트 채점용 사용자 프롬프트 생성 - */ - private String buildReportGradingUserPrompt(String businessPlanContent) { - PromptTemplate promptTemplate = new PromptTemplate(reportGradingUserPromptTemplate); - Map variables = Map.of("businessPlanContent", businessPlanContent); - return promptTemplate.render(variables); - } - - /** - * 체크리스트 채점용 사용자 프롬프트 생성 - */ - private String buildChecklistGradingUserPrompt( - SubSectionType subSectionType, - String content, - List criteria, - List detailedCriteria) { - // 체크리스트 상세 기준 포맷팅 - StringBuilder criteriaBuilder = new StringBuilder(); - for (int i = 0; i < criteria.size() && i < detailedCriteria.size(); i++) { - criteriaBuilder.append(i + 1).append(") ").append(criteria.get(i)).append("\n"); - criteriaBuilder.append(detailedCriteria.get(i)).append("\n\n"); - } - String formattedCriteria = criteriaBuilder.toString().trim(); - - Map variables = new HashMap<>(); - variables.put("subsectionType", subSectionType.getDescription()); - variables.put("checklistCriteria", formattedCriteria); - variables.put("input", content); - variables.put("requestLength", criteria.size()); - - PromptTemplate promptTemplate = new PromptTemplate(checklistGradingUserPromptTemplate); - return promptTemplate.render(variables); - } -} diff --git a/src/main/java/starlight/adapter/ai/util/AiReportResponseParser.java b/src/main/java/starlight/adapter/ai/util/AiReportResponseParser.java deleted file mode 100644 index 7d5c44b..0000000 --- a/src/main/java/starlight/adapter/ai/util/AiReportResponseParser.java +++ /dev/null @@ -1,314 +0,0 @@ -package starlight.adapter.ai.util; - -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.AiReportResponse; -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; - -/** - * LLM 응답을 파싱하여 AiReportResponse로 변환하는 컴포넌트 - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class AiReportResponseParser { - - private final ObjectMapper objectMapper; - - /** - * AiReportResponse를 JsonNode로 변환 (저장용) - * 또는 JsonNode에서 AiReportResponse로 변환 (조회용) - * 통합된 변환 메소드 - */ - public JsonNode convertToJsonNode(AiReportResponse 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 (AiReportResponse.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 (AiReportResponse.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 (AiReportResponse.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 AiReportResponse toResponse(AiReport aiReport) { - JsonNode jsonNode = aiReport.getRawJson().asTree(); - - // 공통 파싱 로직 재사용 - AiReportResponse 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 AiReportResponse( - aiReport.getId(), - aiReport.getBusinessPlanId(), - totalScore, - baseResponse.problemRecognitionScore(), - baseResponse.feasibilityScore(), - baseResponse.growthStrategyScore(), - baseResponse.teamCompetenceScore(), - baseResponse.sectionScores(), - baseResponse.strengths(), - baseResponse.weaknesses() - ); - } - - /** - * 응답이 기본값(파싱 실패 시 반환되는 값)인지 확인 - */ - private boolean isDefaultResponse(AiReportResponse 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로 파싱 - * 파싱 실패 시 예외를 던집니다. - */ - public AiReportResponse parse(String llmResponse) { - log.debug("Raw LLM response: {}", llmResponse); - - // 1. 기본 검증 - if (llmResponse == null || llmResponse.trim().isEmpty()) { - log.error("LLM response is null or empty"); - throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); - } - - try { - // 2. JSON 문자열 정리 - String cleanedJson = cleanJsonResponse(llmResponse); - log.debug("Cleaned JSON: {}", cleanedJson); - - // 3. JSON 파싱 시도 - JsonNode jsonNode = objectMapper.readTree(cleanedJson); - - // 4. 필수 필드 존재 여부 확인 - if (!jsonNode.has("problemRecognitionScore") || - !jsonNode.has("feasibilityScore") || - !jsonNode.has("growthStrategyScore") || - !jsonNode.has("teamCompetenceScore")) { - throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); - } - - // 5. 파싱 시도 - AiReportResponse response = parseFromJsonNode(jsonNode); - - // 6. 파싱된 값이 기본값인지 확인 - if (isDefaultResponse(response)) { - log.error("Parsed response is default (all zeros), likely parsing failure"); - throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); - } - - return response; - } catch (Exception e) { - log.error("Failed to parse LLM response. Response: {}", llmResponse, e); - throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); - } - } - - /** - * JSON 응답 문자열 정리 및 복구 - */ - private String cleanJsonResponse(String json) { - if (json == null || json.trim().isEmpty()) { - return "{}"; - } - - String cleaned = json.trim(); - - // 1. JSON 코드 블록 마커 제거 (```json ... ``` 또는 ``` ... ```) - if (cleaned.startsWith("```json")) { - cleaned = cleaned.substring(7); - } else if (cleaned.startsWith("```")) { - cleaned = cleaned.substring(3); - } - if (cleaned.endsWith("```")) { - cleaned = cleaned.substring(0, cleaned.length() - 3); - } - cleaned = cleaned.trim(); - - // 2. "text" 필드에서 JSON 추출 (더 강력한 추출) - // 정규식으로 "text" 필드 추출 시도 - if (cleaned.contains("\"text\"") || cleaned.contains("'text'")) { - try { - // 먼저 JSON 파싱 시도 - JsonNode root = objectMapper.readTree(cleaned); - if (root.has("text") && root.get("text").isTextual()) { - cleaned = root.get("text").asText(); - } - } catch (Exception e) { - // JSON 파싱 실패 시 정규식으로 추출 시도 - try { - // "text" : "..." 패턴 찾기 - java.util.regex.Pattern pattern = java.util.regex.Pattern.compile( - "\"text\"\\s*:\\s*\"(.*)\"", - java.util.regex.Pattern.DOTALL - ); - java.util.regex.Matcher matcher = pattern.matcher(cleaned); - if (matcher.find()) { - String extracted = matcher.group(1); - // 이스케이프된 문자 처리 - extracted = extracted.replace("\\n", "\n") - .replace("\\\"", "\"") - .replace("\\\\", "\\"); - cleaned = extracted; - log.debug("Extracted text field using regex"); - } - } catch (Exception e2) { - log.warn("Failed to extract text field using regex: {}", e2.getMessage()); - } - } - } - - // 3. 잘못된 따옴표 패턴 수정 (공백이 포함된 필드명) - cleaned = cleaned.replaceAll("\"\\s+([a-zA-Z_][a-zA-Z0-9_]*)\\s+\"", "\"$1\""); - - return cleaned; - } - - /** - * JsonNode를 파싱하여 AiReportResponse로 변환 - */ - private AiReportResponse parseFromJsonNode(JsonNode jsonNode) { - Integer problemRecognitionScore = jsonNode.path("problemRecognitionScore").asInt(0); - Integer feasibilityScore = jsonNode.path("feasibilityScore").asInt(0); - Integer growthStrategyScore = jsonNode.path("growthStrategyScore").asInt(0); - Integer teamCompetenceScore = jsonNode.path("teamCompetenceScore").asInt(0); - - // 강점 파싱 - List strengths = parseStrengthWeaknessList(jsonNode.path("strengths")); - - // 약점 파싱 - List weaknesses = parseStrengthWeaknessList(jsonNode.path("weaknesses")); - - // sectionScores 파싱: sectionType과 gradingListScores만 포함 - List sectionScores = parseSectionScores( - jsonNode.path("sectionScores")); - - return AiReportResponse.fromGradingResult( - problemRecognitionScore, - feasibilityScore, - growthStrategyScore, - teamCompetenceScore, - sectionScores, - strengths, - weaknesses); - } - - /** - * 강점/약점 리스트 파싱 - */ - private List parseStrengthWeaknessList(JsonNode node) { - List list = new ArrayList<>(); - if (node.isArray()) { - for (JsonNode itemNode : node) { - list.add(new AiReportResponse.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(""); - String gradingListScores = sectionScoreNode.path("gradingListScores").asText("[]"); - - // gradingListScores가 유효한 JSON 문자열인지 검증 - if (!gradingListScores.equals("[]")) { - 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) { - log.warn("Failed to parse gradingListScores for sectionType: {}, using default. Value: {}", - sectionType, gradingListScores); - gradingListScores = "[]"; - } - } - - list.add(new AiReportResponse.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/ai/util/ChecklistCatalog.java b/src/main/java/starlight/adapter/ai/util/ChecklistCatalog.java deleted file mode 100644 index 86a5236..0000000 --- a/src/main/java/starlight/adapter/ai/util/ChecklistCatalog.java +++ /dev/null @@ -1,65 +0,0 @@ -package starlight.adapter.ai.util; - -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; -import starlight.domain.businessplan.enumerate.SubSectionType; - -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -@Component -@ConfigurationProperties(prefix = "prompt.checklist") -@Getter -@Setter -public class ChecklistCatalog { - - private Map catalog; - - @Getter - @Setter - public static class CatalogSection { - private List items; - } - - @Getter - @Setter - public static class ChecklistItem { - private String criteria; - private String detailed; - } - - // 서브섹션 타입에 해당하는 criteria 리스트를 반환합니다 - public List getCriteriaBySubSectionType(SubSectionType subSectionType) { - String tag = subSectionType.getTag(); - if (catalog == null || !catalog.containsKey(tag)) { - return List.of(); - } - CatalogSection section = catalog.get(tag); - if (section == null || section.getItems() == null) { - return List.of(); - } - return section.getItems().stream() - .map(ChecklistItem::getCriteria) - .filter(c -> c != null && !c.isEmpty()) - .collect(Collectors.toList()); - } - - // 서브섹션 타입에 해당하는 detailed-criteria 리스트를 반환합니다. - public List getDetailedCriteriaBySubSectionType(SubSectionType subSectionType) { - String tag = subSectionType.getTag(); - if (catalog == null || !catalog.containsKey(tag)) { - return List.of(); - } - CatalogSection section = catalog.get(tag); - if (section == null || section.getItems() == null) { - return List.of(); - } - return section.getItems().stream() - .map(ChecklistItem::getDetailed) - .filter(d -> d != null && !d.isEmpty()) - .collect(Collectors.toList()); - } -} diff --git a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpaPort.java b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpaPort.java deleted file mode 100644 index a9a9a86..0000000 --- a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpaPort.java +++ /dev/null @@ -1,78 +0,0 @@ -package starlight.adapter.expertApplication.persistence; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; -import starlight.application.expertApplication.required.ExpertApplicationQueryPort; -import starlight.domain.expertApplication.entity.ExpertApplication; -import starlight.domain.expertApplication.exception.ExpertApplicationErrorType; -import starlight.domain.expertApplication.exception.ExpertApplicationException; - -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -@Slf4j -@Component -@RequiredArgsConstructor -public class ExpertApplicationJpaPort implements ExpertApplicationQueryPort, - starlight.application.expert.required.ExpertApplicationCountLookupPort, - starlight.application.expertReport.required.ExpertApplicationCountLookupPort { - - private final ExpertApplicationRepository repository; - - @Override - public Boolean existsByExpertIdAndBusinessPlanId(Long expertId, Long businessPlanId) { - try { - return repository.existsByExpertIdAndBusinessPlanId(expertId, businessPlanId); - } catch (Exception e) { - log.error("전문가 신청 존재 여부 조회 중 오류가 발생했습니다.", e); - throw new ExpertApplicationException(ExpertApplicationErrorType.EXPERT_APPLICATION_QUERY_ERROR); - } - } - - @Override - public ExpertApplication save(ExpertApplication application) { - return repository.save(application); - } - - @Override - public Map countByExpertIds(List expertIds) { - try { - if (expertIds == null || expertIds.isEmpty()) { - return Collections.emptyMap(); - } - - return repository.countByExpertIds(expertIds).stream() - .collect(Collectors.toMap( - ExpertApplicationRepository.ExpertIdCountProjection::getExpertId, - p -> (long) p.getCount() - )); - } catch (Exception e) { - log.error("전문가별 신청 건수 조회 중 오류가 발생했습니다.", e); - throw new ExpertApplicationException(ExpertApplicationErrorType.EXPERT_APPLICATION_QUERY_ERROR); - } - } - - @Override - public Map countByExpertIdAndBusinessPlanIds(Long expertId, List businessPlanIds) { - try { - if (expertId == null) { - return Collections.emptyMap(); - } - if (businessPlanIds == null || businessPlanIds.isEmpty()) { - return Collections.emptyMap(); - } - - return repository.countByExpertIdAndBusinessPlanIds(expertId, businessPlanIds).stream() - .collect(Collectors.toMap( - ExpertApplicationRepository.BusinessPlanIdCountProjection::getBusinessPlanId, - p -> (long) p.getCount() - )); - } catch (Exception e) { - log.error("사업계획서별 신청 건수 조회 중 오류가 발생했습니다.", e); - throw new ExpertApplicationException(ExpertApplicationErrorType.EXPERT_APPLICATION_QUERY_ERROR); - } - } -} diff --git a/src/main/java/starlight/application/aireport/provided/AiReportService.java b/src/main/java/starlight/application/aireport/provided/AiReportService.java deleted file mode 100644 index 6c618fd..0000000 --- a/src/main/java/starlight/application/aireport/provided/AiReportService.java +++ /dev/null @@ -1,11 +0,0 @@ -package starlight.application.aireport.provided; - -import starlight.application.aireport.provided.dto.AiReportResponse; - -public interface AiReportService { - AiReportResponse gradeBusinessPlan(Long businessPlanId, Long memberId); - - AiReportResponse createAndGradePdfBusinessPlan(String title, String pdfUrl, Long memberId); - - AiReportResponse getAiReport(Long businessPlanId, Long memberId); -} \ No newline at end of file diff --git a/src/main/java/starlight/application/aireport/provided/dto/AiReportResponse.java b/src/main/java/starlight/application/aireport/provided/dto/AiReportResponse.java deleted file mode 100644 index 93ec0c0..0000000 --- a/src/main/java/starlight/application/aireport/provided/dto/AiReportResponse.java +++ /dev/null @@ -1,67 +0,0 @@ -package starlight.application.aireport.provided.dto; - -import com.fasterxml.jackson.annotation.JsonRawValue; -import java.util.List; - -/** - * AI 리포트 응답 DTO - * LLM 채점 결과와 API 응답을 모두 담는 통합 DTO - */ -public record AiReportResponse( - Long id, // null 가능 (LLM 결과 파싱 시에는 null) - Long businessPlanId, // null 가능 (LLM 결과 파싱 시에는 null) - Integer totalScore, - Integer problemRecognitionScore, - Integer feasibilityScore, - Integer growthStrategyScore, - Integer teamCompetenceScore, - List sectionScores, - List strengths, - List weaknesses -) { - public record SectionScoreDetailResponse( - String sectionType, - @JsonRawValue String gradingListScores - ) {} - - public record StrengthWeakness( - String title, - String content - ) {} - - /** - * LLM 결과만으로 AiReportResponse 생성 (id, businessPlanId는 null) - */ - public static AiReportResponse fromGradingResult( - Integer problemRecognitionScore, - Integer feasibilityScore, - Integer growthStrategyScore, - Integer teamCompetenceScore, - List sectionScores, - List strengths, - List weaknesses - ) { - Integer totalScore = sumTotalScore(problemRecognitionScore, feasibilityScore, growthStrategyScore, teamCompetenceScore); - - return new AiReportResponse( - null, - null, - totalScore, - problemRecognitionScore, - feasibilityScore, - growthStrategyScore, - teamCompetenceScore, - sectionScores, - strengths, - weaknesses - ); - } - - private static Integer sumTotalScore(Integer problemRecognitionScore, Integer feasibilityScore, Integer growthStrategyScore, Integer teamCompetenceScore) { - return (problemRecognitionScore != null ? problemRecognitionScore : 0) + - (feasibilityScore != null ? feasibilityScore : 0) + - (growthStrategyScore != null ? growthStrategyScore : 0) + - (teamCompetenceScore != null ? teamCompetenceScore : 0); - } -} - diff --git a/src/main/java/starlight/application/aireport/required/AiReportGrader.java b/src/main/java/starlight/application/aireport/required/AiReportGrader.java deleted file mode 100644 index 0ba2d25..0000000 --- a/src/main/java/starlight/application/aireport/required/AiReportGrader.java +++ /dev/null @@ -1,7 +0,0 @@ -package starlight.application.aireport.required; - -import starlight.application.aireport.provided.dto.AiReportResponse; - -public interface AiReportGrader { - AiReportResponse gradeContent(String content); -} \ No newline at end of file diff --git a/src/main/java/starlight/application/aireport/required/AiReportQuery.java b/src/main/java/starlight/application/aireport/required/AiReportQuery.java deleted file mode 100644 index 8e18704..0000000 --- a/src/main/java/starlight/application/aireport/required/AiReportQuery.java +++ /dev/null @@ -1,11 +0,0 @@ -package starlight.application.aireport.required; - -import starlight.domain.aireport.entity.AiReport; - -import java.util.Optional; - -public interface AiReportQuery { - AiReport save(AiReport aiReport); - Optional findByBusinessPlanId(Long businessPlanId); -} - diff --git a/src/main/java/starlight/application/aireport/required/OcrProvider.java b/src/main/java/starlight/application/aireport/required/OcrProvider.java deleted file mode 100644 index 0e8050d..0000000 --- a/src/main/java/starlight/application/aireport/required/OcrProvider.java +++ /dev/null @@ -1,10 +0,0 @@ -package starlight.application.aireport.required; - -import starlight.shared.dto.infrastructure.OcrResponse; - -public interface OcrProvider { - - OcrResponse ocrPdfByUrl(String pdfUrl) ; - - String ocrPdfTextByUrl(String pdfUrl); -} diff --git a/src/main/java/starlight/application/aireport/required/PresignedUrlProvider.java b/src/main/java/starlight/application/aireport/required/PresignedUrlProvider.java deleted file mode 100644 index 0a262be..0000000 --- a/src/main/java/starlight/application/aireport/required/PresignedUrlProvider.java +++ /dev/null @@ -1,10 +0,0 @@ -package starlight.application.aireport.required; - -import starlight.shared.dto.infrastructure.PreSignedUrlResponse; - -public interface PresignedUrlProvider { - - PreSignedUrlResponse getPreSignedUrl(Long userId, String originalFileName); - - String makePublic(String objectUrl); -} diff --git a/src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java b/src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java deleted file mode 100644 index 157b89b..0000000 --- a/src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java +++ /dev/null @@ -1,256 +0,0 @@ -package starlight.application.businessplan; - -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 org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Page; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import starlight.application.businessplan.provided.dto.BusinessPlanResponse; -import starlight.application.businessplan.provided.dto.SubSectionResponse; -import starlight.application.businessplan.provided.BusinessPlanService; -import starlight.application.businessplan.required.BusinessPlanQuery; -import starlight.application.businessplan.required.ChecklistGrader; -import starlight.application.businessplan.util.PlainTextExtractUtils; -import starlight.application.businessplan.util.SubSectionSupportUtils; -import starlight.application.member.required.MemberQueryPort; -import starlight.domain.businessplan.entity.*; -import starlight.domain.businessplan.enumerate.PlanStatus; -import starlight.domain.member.entity.Member; -import starlight.shared.enumerate.SectionType; -import starlight.domain.businessplan.enumerate.SubSectionType; -import starlight.domain.businessplan.exception.BusinessPlanErrorType; -import starlight.domain.businessplan.exception.BusinessPlanException; - -import java.util.Arrays; -import java.util.List; -import java.util.Objects; - -@Service -@RequiredArgsConstructor -@Transactional -public class BusinessPlanServiceImpl implements BusinessPlanService { - - private final BusinessPlanQuery businessPlanQuery; - private final MemberQueryPort memberQuery; - private final ChecklistGrader checklistGrader; - private final ObjectMapper objectMapper; - - @Override - public BusinessPlanResponse.Result createBusinessPlan(Long memberId) { - Member member = memberQuery.findByIdOrThrow(memberId); - - String planTitle = member.getName() == null ? "제목 없는 사업계획서" : member.getName() + "의 사업계획서"; - - BusinessPlan plan = BusinessPlan.create(planTitle, memberId); - - return BusinessPlanResponse.Result.from(businessPlanQuery.save(plan), "Business plan created"); - } - - @Override - public BusinessPlanResponse.Result createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId) { - BusinessPlan plan = BusinessPlan.createWithPdf( - title, - memberId, - pdfUrl - ); - - return BusinessPlanResponse.Result.from(businessPlanQuery.save(plan), "PDF Business plan created"); - } - - @Override - @Transactional(readOnly = true) - public BusinessPlanResponse.Result getBusinessPlanInfo(Long planId, Long memberId) { - BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); - - return BusinessPlanResponse.Result.from(plan, "Business plan retrieved"); - } - - @Override - @Transactional(readOnly = true) - public BusinessPlanResponse.Detail getBusinessPlanDetail(Long planId, Long memberId) { - BusinessPlan plan = businessPlanQuery.getOrThrowWithAllSubSections(planId); - if (!plan.isOwnedBy(memberId)) { - throw new BusinessPlanException(BusinessPlanErrorType.UNAUTHORIZED_ACCESS); - } - - List subSectionDetailList = Arrays.stream(SubSectionType.values()) - .map(type -> getSectionByPlanAndType(plan, type.getSectionType()).getSubSectionByType(type)) - .filter(Objects::nonNull) - .map(SubSectionResponse.Detail::from) - .toList(); - - return BusinessPlanResponse.Detail.from(plan, subSectionDetailList); - } - - @Override - @Transactional(readOnly = true) - public BusinessPlanResponse.PreviewPage getBusinessPlanList(Long memberId, Pageable pageable) { - Page page = businessPlanQuery.findPreviewPage(memberId, pageable); - List content = page.getContent().stream() - .map(BusinessPlanResponse.Preview::from) - .toList(); - - return BusinessPlanResponse.PreviewPage.from(content, page); - } - - @Override - public String updateBusinessPlanTitle(Long planId, String title, Long memberId) { - BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); - - plan.updateTitle(title); - - businessPlanQuery.save(plan); - - return plan.getTitle(); - } - - @Override - public BusinessPlanResponse.Result deleteBusinessPlan(Long planId, Long memberId) { - BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); - - BusinessPlanResponse.Result result = BusinessPlanResponse.Result.from(plan, "Business plan deleted"); - businessPlanQuery.delete(plan); - - return result; - } - - @Override - public SubSectionResponse.Result upsertSubSection( - Long planId, - JsonNode jsonNode, - List checks, - SubSectionType subSectionType, - Long memberId - ) { - BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); - - SectionType sectionType = subSectionType.getSectionType(); - BaseSection section = getSectionByPlanAndType(plan, sectionType); - SubSection subSection = section.getSubSectionByType(subSectionType); - - String rawJsonStr = getSerializedJsonNodesWithUpdatedChecks(jsonNode, checks); - String content = PlainTextExtractUtils.extractPlainText(objectMapper, jsonNode); - - String message; - - if (subSection == null) { - SubSection newSubSection = SubSection.create(subSectionType, content, rawJsonStr, checks); - section.putSubSection(newSubSection); - message = "Subsection created"; - } else { - subSection.update(content, rawJsonStr, checks); - message = "Subsection updated"; - } - - if (plan.areWritingCompleted()) { - plan.updateStatus(PlanStatus.WRITTEN_COMPLETED); - message = "Subsection writing completed"; - } - - BusinessPlan savedPlan = businessPlanQuery.save(plan); - SubSection persistedSubSection = getSectionByPlanAndType(savedPlan, sectionType) - .getSubSectionByType(subSectionType); - - return SubSectionResponse.Result.from(persistedSubSection, message); - } - - @Override - @Transactional(readOnly = true) - public SubSectionResponse.Detail getSubSectionDetail(Long planId, SubSectionType subSectionType, Long memberId) { - BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); - - SectionType sectionType = subSectionType.getSectionType(); - SubSection subSection = getSectionByPlanAndType(plan, sectionType).getSubSectionByType(subSectionType); - if (subSection == null) { - throw new BusinessPlanException(BusinessPlanErrorType.SUBSECTION_NOT_FOUND); - } - - return SubSectionResponse.Detail.from(subSection); - } - - @Override - public List checkAndUpdateSubSection( - Long planId, - JsonNode jsonNode, - SubSectionType subSectionType, - Long memberId) { - BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); - - SectionType sectionType = subSectionType.getSectionType(); - SubSection subSection = getSectionByPlanAndType(plan, sectionType).getSubSectionByType(subSectionType); - if (subSection == null) { - throw new BusinessPlanException(BusinessPlanErrorType.SUBSECTION_NOT_FOUND); - } - - String content = PlainTextExtractUtils.extractPlainText(objectMapper, jsonNode); - - List checks = checklistGrader.check(subSectionType, content); - - SubSectionSupportUtils.requireSize(checks, SubSection.getCHECKLIST_SIZE()); - String rawJsonStr = getSerializedJsonNodesWithUpdatedChecks(jsonNode, checks); - - subSection.update(content, rawJsonStr, checks); - - businessPlanQuery.save(plan); - - return checks; - } - - @Override - public SubSectionResponse.Result deleteSubSection(Long planId, SubSectionType subSectionType, Long memberId) { - BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); - - SectionType sectionType = subSectionType.getSectionType(); - BaseSection section = getSectionByPlanAndType(plan, sectionType); - SubSection target = section.getSubSectionByType(subSectionType); - if (target == null) { - throw new BusinessPlanException(BusinessPlanErrorType.SUBSECTION_NOT_FOUND); - } - SubSectionResponse.Result result = SubSectionResponse.Result.from(target, "Subsection deleted"); - section.removeSubSection(subSectionType); - - businessPlanQuery.save(plan); - - return result; - } - - private String getSerializedJsonNodesWithUpdatedChecks(JsonNode jsonNode, List checks) { - - ObjectNode updatedJsonNode = (ObjectNode) objectMapper.valueToTree(jsonNode); - - ArrayNode checkListArray; - if (updatedJsonNode.has("checks") && updatedJsonNode.get("checks").isArray()) { - checkListArray = (ArrayNode) updatedJsonNode.get("checks"); - checkListArray.removeAll(); - - for (Boolean check : checks) { - checkListArray.add(check); - } - } - - return SubSectionSupportUtils.serializeJsonNodeSafely(objectMapper, updatedJsonNode); - } - - private BusinessPlan getOwnedBusinessPlanOrThrow(Long planId, Long memberId) { - BusinessPlan businessPlan = businessPlanQuery.findByIdOrThrow(planId); - if (!businessPlan.isOwnedBy(memberId)) { - throw new BusinessPlanException(BusinessPlanErrorType.UNAUTHORIZED_ACCESS); - } - return businessPlan; - } - - private BaseSection getSectionByPlanAndType(BusinessPlan plan, SectionType type) { - return switch (type) { - case OVERVIEW -> plan.getOverview(); - case PROBLEM_RECOGNITION -> plan.getProblemRecognition(); - case FEASIBILITY -> plan.getFeasibility(); - case GROWTH_STRATEGY -> plan.getGrowthTactic(); - case TEAM_COMPETENCE -> plan.getTeamCompetence(); - default -> throw new IllegalArgumentException("Unsupported section: " + type); - }; - } -} diff --git a/src/main/java/starlight/application/businessplan/provided/BusinessPlanService.java b/src/main/java/starlight/application/businessplan/provided/BusinessPlanService.java deleted file mode 100644 index 79f5d48..0000000 --- a/src/main/java/starlight/application/businessplan/provided/BusinessPlanService.java +++ /dev/null @@ -1,38 +0,0 @@ -package starlight.application.businessplan.provided; - -import com.fasterxml.jackson.databind.JsonNode; -import org.springframework.data.domain.Pageable; -import starlight.application.businessplan.provided.dto.BusinessPlanResponse; -import starlight.application.businessplan.provided.dto.SubSectionResponse; -import starlight.domain.businessplan.enumerate.SubSectionType; - -import java.util.List; - -public interface BusinessPlanService { - - BusinessPlanResponse.Result createBusinessPlan(Long memberId); - - BusinessPlanResponse.Result createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId); - - BusinessPlanResponse.Result getBusinessPlanInfo(Long planId, Long memberId); - - BusinessPlanResponse.Detail getBusinessPlanDetail(Long planId, Long memberId); - - BusinessPlanResponse.PreviewPage getBusinessPlanList(Long memberId, Pageable pageable); - - String updateBusinessPlanTitle(Long planId, String title, Long memberId); - - BusinessPlanResponse.Result deleteBusinessPlan(Long planId, Long memberId); - - SubSectionResponse.Result upsertSubSection(Long planId, JsonNode jsonNode, List checks, - SubSectionType subSectionType, Long memberId); - - SubSectionResponse.Detail getSubSectionDetail(Long planId, SubSectionType subSectionType, Long memberId); - - List checkAndUpdateSubSection(Long planId, JsonNode jsonNode, SubSectionType subSectionType, - Long memberId); - - SubSectionResponse.Result deleteSubSection(Long planId, SubSectionType subSectionType, Long memberId); - - -} diff --git a/src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResponse.java b/src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResponse.java deleted file mode 100644 index 6f83746..0000000 --- a/src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResponse.java +++ /dev/null @@ -1,93 +0,0 @@ -package starlight.application.businessplan.provided.dto; - -import com.fasterxml.jackson.annotation.JsonFormat; -import org.springframework.data.domain.Page; -import starlight.domain.businessplan.entity.BusinessPlan; -import starlight.domain.businessplan.enumerate.PlanStatus; - -import java.time.LocalDateTime; -import java.util.List; - -public record BusinessPlanResponse() { - - public record Result( - Long businessPlanId, - String title, - PlanStatus planStatus, - String message - ) { - public static Result from(BusinessPlan businessPlan, String message) { - return new Result( - businessPlan.getId(), - businessPlan.getTitle(), - businessPlan.getPlanStatus(), - message - ); - } - } - - public record Detail( - Long businessPlanId, - String title, - PlanStatus planStatus, - List subSectionDetailList - ) { - public static Detail from( - BusinessPlan businessPlan, - List subSectionDetailList - ) { - return new Detail( - businessPlan.getId(), - businessPlan.getTitle(), - businessPlan.getPlanStatus(), - subSectionDetailList - ); - } - } - - public record Preview( - Long businessPlanId, - String title, - String pdfUrl, - @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") LocalDateTime lastSavedAt, - PlanStatus planStatus - ) { - public static Preview from(BusinessPlan businessPlan) { - LocalDateTime lastSavedAt = businessPlan.getModifiedAt() != null - ? businessPlan.getModifiedAt() - : businessPlan.getCreatedAt(); - - return new Preview( - businessPlan.getId(), - businessPlan.getTitle(), - businessPlan.isPdfBased() ? businessPlan.getPdfUrl() : null, - lastSavedAt, - businessPlan.getPlanStatus() - ); - } - } - - public record PreviewPage( - List content, - int page, - int size, - int totalPages, - long totalElements, - int numberOfElements, - boolean first, - boolean last - ) { - public static PreviewPage from(List content, Page page) { - return new BusinessPlanResponse.PreviewPage( - content, - page.getNumber() + 1, - page.getSize(), - page.getTotalPages(), - page.getTotalElements(), - page.getNumberOfElements(), - page.isFirst(), - page.isLast() - ); - } - } -} diff --git a/src/main/java/starlight/application/businessplan/provided/dto/SubSectionResponse.java b/src/main/java/starlight/application/businessplan/provided/dto/SubSectionResponse.java deleted file mode 100644 index 019721d..0000000 --- a/src/main/java/starlight/application/businessplan/provided/dto/SubSectionResponse.java +++ /dev/null @@ -1,39 +0,0 @@ -package starlight.application.businessplan.provided.dto; - -import com.fasterxml.jackson.databind.JsonNode; -import starlight.domain.businessplan.entity.SubSection; -import starlight.domain.businessplan.enumerate.SubSectionType; - -public record SubSectionResponse() { - - public record Result( - SubSectionType subSectionType, - Long subSectionId, - String message - ) { - public static Result from( - SubSection subSection, - String message - ) { - return new Result( - subSection.getSubSectionType(), - subSection.getId(), - message - ); - } - } - - public record Detail( - SubSectionType subSectionType, - Long subSectionId, - JsonNode content - ) { - public static Detail from(SubSection subSection) { - return new Detail( - subSection.getSubSectionType(), - subSection.getId(), - subSection.getRawJson().asTree() - ); - } - } -} \ No newline at end of file diff --git a/src/main/java/starlight/application/businessplan/required/BusinessPlanQuery.java b/src/main/java/starlight/application/businessplan/required/BusinessPlanQuery.java deleted file mode 100644 index 355ec8f..0000000 --- a/src/main/java/starlight/application/businessplan/required/BusinessPlanQuery.java +++ /dev/null @@ -1,18 +0,0 @@ -package starlight.application.businessplan.required; - -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Page; -import starlight.domain.businessplan.entity.BusinessPlan; - -public interface BusinessPlanQuery { - - BusinessPlan findByIdOrThrow(Long id); - - BusinessPlan getOrThrowWithAllSubSections(Long id); - - BusinessPlan save(BusinessPlan businessPlan); - - void delete(BusinessPlan businessPlan); - - Page findPreviewPage(Long memberId, Pageable pageable); -} diff --git a/src/main/java/starlight/application/businessplan/required/ChecklistGrader.java b/src/main/java/starlight/application/businessplan/required/ChecklistGrader.java deleted file mode 100644 index 33049e2..0000000 --- a/src/main/java/starlight/application/businessplan/required/ChecklistGrader.java +++ /dev/null @@ -1,20 +0,0 @@ -package starlight.application.businessplan.required; - -import starlight.domain.businessplan.enumerate.SubSectionType; - -import java.util.List; - -public interface ChecklistGrader { - - /** - * 서브섹션 내용을 체크리스트 기준에 따라 체크합니다. - * - * @param subSectionType 서브섹션 타입 - * @param content 서브섹션 내용 - * @return 체크리스트 결과 - */ - List check( - SubSectionType subSectionType, - String content - ); -} diff --git a/src/main/java/starlight/application/businessplan/required/SpellChecker.java b/src/main/java/starlight/application/businessplan/required/SpellChecker.java deleted file mode 100644 index 347abd5..0000000 --- a/src/main/java/starlight/application/businessplan/required/SpellChecker.java +++ /dev/null @@ -1,12 +0,0 @@ -package starlight.application.businessplan.required; - -import starlight.adapter.businessplan.spellcheck.dto.Finding; - -import java.util.List; - -public interface SpellChecker { - - List check(String sentence); - - String applyTopSuggestions(String original, List findings); -} \ No newline at end of file diff --git a/src/main/java/starlight/application/infrastructure/provided/LlmGenerator.java b/src/main/java/starlight/application/infrastructure/provided/LlmGenerator.java deleted file mode 100644 index 58b7868..0000000 --- a/src/main/java/starlight/application/infrastructure/provided/LlmGenerator.java +++ /dev/null @@ -1,12 +0,0 @@ -package starlight.application.infrastructure.provided; - -import starlight.domain.businessplan.enumerate.SubSectionType; - -import java.util.List; - -public interface LlmGenerator { - - List generateChecklistArray(SubSectionType subSectionType, String content, List criteria, List detailedCriteria); - - String generateReport(String content); -} From eb3f5b09508cbdb08aa78328a450d9051fed08a3 Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Mon, 5 Jan 2026 22:41:50 +0900 Subject: [PATCH 07/74] =?UTF-8?q?[SRLT-124]=20Refactor:=20Adapter=20?= =?UTF-8?q?=EB=B0=8F=20Application=20=EB=A0=88=EC=9D=B4=EC=96=B4=EB=A5=BC?= =?UTF-8?q?=20=EC=83=88=EB=A1=9C=EC=9A=B4=20=EC=9D=B8=ED=84=B0=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=EC=97=90=20=EB=A7=9E=EA=B2=8C=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 --- .../aireport/persistence/AiReportJpa.java | 7 +- .../aireport/webapi/AiReportController.java | 18 ++-- .../aireport/webapi/ImageController.java | 4 +- .../persistence/BusinessPlanJpa.java | 7 +- .../webapi/BusinessPlanController.java | 24 ++--- .../businessplan/webapi/SpellController.java | 4 +- .../webapi/ExpertReportController.java | 4 +- .../member/webapi/MemberController.java | 4 +- .../application/aireport/AiReportService.java | 102 ++++++++++++++---- .../ExpertApplicationCommandService.java | 8 +- .../expertReport/ExpertReportService.java | 8 +- .../provided/ExpertReportUseCase.java | 2 +- .../application/member/CredentialService.java | 4 +- .../application/member/MemberService.java | 4 +- .../member/auth/AuthServiceImpl.java | 8 +- .../member/provided/CredentialUseCase.java | 2 +- .../member/provided/MemberUseCase.java | 2 +- 17 files changed, 136 insertions(+), 76 deletions(-) diff --git a/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java b/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java index a551346..82330a4 100644 --- a/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java +++ b/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java @@ -2,8 +2,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; -import starlight.adapter.ai.util.AiReportResponseParser; -import starlight.application.aireport.required.AiReportQuery; +import starlight.adapter.aireport.report.util.AiReportResponseParser; +import starlight.application.aireport.required.AiReportCommandPort; +import starlight.application.aireport.required.AiReportQueryPort; import starlight.application.expert.required.AiReportSummaryLookupPort; import starlight.domain.aireport.entity.AiReport; @@ -15,7 +16,7 @@ @Component @RequiredArgsConstructor -public class AiReportJpa implements AiReportQuery, AiReportSummaryLookupPort { +public class AiReportJpa implements AiReportCommandPort, AiReportQueryPort, AiReportSummaryLookupPort { private final AiReportRepository aiReportRepository; private final AiReportResponseParser responseParser; diff --git a/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java b/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java index e39411b..6d917d0 100644 --- a/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java +++ b/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java @@ -10,8 +10,8 @@ import org.springframework.web.bind.annotation.*; import starlight.adapter.businessplan.webapi.dto.BusinessPlanCreateWithPdfRequest; import starlight.adapter.member.auth.security.auth.AuthDetails; -import starlight.application.aireport.provided.dto.AiReportResponse; -import starlight.application.aireport.provided.AiReportService; +import starlight.application.aireport.provided.dto.AiReportResult; +import starlight.application.aireport.provided.AiReportUseCase; import starlight.shared.apiPayload.response.ApiResponse; @Validated @@ -22,24 +22,24 @@ @SecurityRequirement(name = "bearerAuth") public class AiReportController { - private final AiReportService aiReportService; + private final AiReportUseCase aiReportUseCase; @Operation(summary = "사업계획서를 AI로 채점 및 생성합니다.") @PostMapping("/evaluation/{planId}") - public ApiResponse gradeBusinessPlan( + public ApiResponse gradeBusinessPlan( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId ) { - return ApiResponse.success(aiReportService.gradeBusinessPlan(planId, authDetails.getMemberId())); + return ApiResponse.success(aiReportUseCase.gradeBusinessPlan(planId, authDetails.getMemberId())); } @Operation(summary = "PDF URL을 기반으로 사업계획서를 생성하고, AI로 채점 및 생성합니다.") @PostMapping("/evaluation/pdf") - public ApiResponse createAndGradeBusinessPlan( + public ApiResponse createAndGradeBusinessPlan( @AuthenticationPrincipal AuthDetails authDetails, @Valid @RequestBody BusinessPlanCreateWithPdfRequest request ) { - return ApiResponse.success(aiReportService.createAndGradePdfBusinessPlan( + return ApiResponse.success(aiReportUseCase.createAndGradePdfBusinessPlan( request.title(), request.pdfUrl(), authDetails.getMemberId() @@ -48,10 +48,10 @@ public ApiResponse createAndGradeBusinessPlan( @Operation(summary = "AI 리포트를 조회합니다.") @GetMapping("/{planId}") - public ApiResponse getAiReport( + public ApiResponse getAiReport( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId ) { - return ApiResponse.success(aiReportService.getAiReport(planId, authDetails.getMemberId())); + return ApiResponse.success(aiReportUseCase.getAiReport(planId, authDetails.getMemberId())); } } diff --git a/src/main/java/starlight/adapter/aireport/webapi/ImageController.java b/src/main/java/starlight/adapter/aireport/webapi/ImageController.java index 442d499..19302ff 100644 --- a/src/main/java/starlight/adapter/aireport/webapi/ImageController.java +++ b/src/main/java/starlight/adapter/aireport/webapi/ImageController.java @@ -5,7 +5,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; -import starlight.application.aireport.required.PresignedUrlProvider; +import starlight.application.aireport.required.PresignedUrlProviderPort; import starlight.adapter.aireport.webapi.swagger.ImageApiDoc; import starlight.shared.auth.AuthenticatedMember; import starlight.shared.apiPayload.response.ApiResponse; @@ -15,7 +15,7 @@ @RequiredArgsConstructor public class ImageController implements ImageApiDoc { - private final PresignedUrlProvider presignedUrlReader; + private final PresignedUrlProviderPort presignedUrlReader; @GetMapping(value = "/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) public ApiResponse getPresignedUrl( diff --git a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java index 8966aef..ada1b46 100644 --- a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java +++ b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java @@ -4,7 +4,8 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; -import starlight.application.businessplan.required.BusinessPlanQuery; +import starlight.application.businessplan.required.BusinessPlanCommandPort; +import starlight.application.businessplan.required.BusinessPlanQueryPort; import starlight.application.expert.required.BusinessPlanLookupPort; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.exception.BusinessPlanErrorType; @@ -14,7 +15,7 @@ @Repository @RequiredArgsConstructor -public class BusinessPlanJpa implements BusinessPlanQuery, BusinessPlanLookupPort { +public class BusinessPlanJpa implements BusinessPlanCommandPort, BusinessPlanQueryPort, BusinessPlanLookupPort { private final BusinessPlanRepository businessPlanRepository; @@ -26,7 +27,7 @@ public BusinessPlan findByIdOrThrow(Long id) { } @Override - public BusinessPlan getOrThrowWithAllSubSections(Long id) { + public BusinessPlan findWithAllSubSectionsOrThrow(Long id) { return businessPlanRepository.findByIdWithAllSubSections(id).orElseThrow( () -> new BusinessPlanException(BusinessPlanErrorType.BUSINESS_PLAN_NOT_FOUND) ); diff --git a/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java b/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java index 50affe2..63f8444 100644 --- a/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java +++ b/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java @@ -17,9 +17,9 @@ import starlight.adapter.businessplan.webapi.dto.BusinessPlanCreateRequest; import starlight.adapter.businessplan.webapi.dto.BusinessPlanCreateWithPdfRequest; import starlight.adapter.businessplan.webapi.dto.SubSectionCreateRequest; -import starlight.application.businessplan.provided.dto.BusinessPlanResponse; -import starlight.application.businessplan.provided.dto.SubSectionResponse; -import starlight.application.businessplan.provided.BusinessPlanService; +import starlight.application.businessplan.provided.dto.BusinessPlanResult; +import starlight.application.businessplan.provided.dto.SubSectionResult; +import starlight.application.businessplan.provided.BusinessPlanUseCase; import starlight.domain.businessplan.enumerate.SubSectionType; import starlight.shared.apiPayload.response.ApiResponse; @@ -33,12 +33,12 @@ @SecurityRequirement(name = "bearerAuth") public class BusinessPlanController { - private final BusinessPlanService businessPlanService; + private final BusinessPlanUseCase businessPlanService; private final ObjectMapper objectMapper; @GetMapping @Operation(summary = "사업 계획서 목록을 조회합니다. (마이페이지 용)") - public ApiResponse getBusinessPlanList( + public ApiResponse getBusinessPlanList( @AuthenticationPrincipal AuthDetails authDetails, @Parameter(description = "페이지 번호 (1 이상 정수 / 기본 1)") @RequestParam(defaultValue = "1") @Min(1)int page, @Parameter(description = "페이지 크기 (1 이상 정수 / 기본 3)") @RequestParam(defaultValue = "3") @Min(1) int size @@ -52,7 +52,7 @@ public ApiResponse getBusinessPlanList( @GetMapping("/{planId}/subsections") @Operation(summary = "사업 계획서의 제목과 모든 서브섹션 내용을 조회합니다. (미리보기 용)") - public ApiResponse getBusinessPlanDetail( + public ApiResponse getBusinessPlanDetail( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId ) { @@ -75,7 +75,7 @@ public ApiResponse getBusinessPlanTitle( @PostMapping @Operation(summary = "사업 계획서를 생성합니다.") - public ApiResponse createBusinessPlan( + public ApiResponse createBusinessPlan( @AuthenticationPrincipal AuthDetails authDetails ) { return ApiResponse.success(businessPlanService.createBusinessPlan(authDetails.getMemberId())); @@ -83,7 +83,7 @@ public ApiResponse createBusinessPlan( @PostMapping("/pdf") @Operation(summary = "PDF URL을 기반으로 사업계획서를 생성합니다.") - public ApiResponse createBusinessPlanWithPdfAndAiReport( + public ApiResponse createBusinessPlanWithPdfAndAiReport( @AuthenticationPrincipal AuthDetails authDetails, @Valid @RequestBody BusinessPlanCreateWithPdfRequest request ) { @@ -106,7 +106,7 @@ public ApiResponse updateBusinessPlanTitle( @Operation(summary = "사업 계획서를 삭제합니다.") @DeleteMapping("/{planId}") - public ApiResponse deleteBusinessPlan( + public ApiResponse deleteBusinessPlan( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId ) { @@ -117,7 +117,7 @@ public ApiResponse deleteBusinessPlan( @Operation(summary = "서브섹션을 생성 또는 수정합니다.") @PostMapping("/{planId}/subsections") - public ApiResponse upsertSubSection( + public ApiResponse upsertSubSection( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId, @Valid @RequestBody SubSectionCreateRequest request @@ -129,7 +129,7 @@ public ApiResponse upsertSubSection( @Operation(summary = "서브섹션을 조회합니다.") @GetMapping("/{planId}/subsections/{subSectionType}") - public ApiResponse getSubSection( + public ApiResponse getSubSection( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId, @PathVariable SubSectionType subSectionType @@ -153,7 +153,7 @@ public ApiResponse> checkAndUpdateSubSection( @Operation(summary = "서브섹션을 삭제합니다.") @DeleteMapping("/{planId}/subsections/{subSectionType}") - public ApiResponse deleteSubSection( + public ApiResponse deleteSubSection( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId, @PathVariable SubSectionType subSectionType diff --git a/src/main/java/starlight/adapter/businessplan/webapi/SpellController.java b/src/main/java/starlight/adapter/businessplan/webapi/SpellController.java index 71df82e..635d4e3 100644 --- a/src/main/java/starlight/adapter/businessplan/webapi/SpellController.java +++ b/src/main/java/starlight/adapter/businessplan/webapi/SpellController.java @@ -9,7 +9,7 @@ import starlight.adapter.businessplan.webapi.dto.SpellCheckResponse; import starlight.adapter.businessplan.spellcheck.dto.Finding; import starlight.adapter.businessplan.webapi.swagger.SpellCheckApiDoc; -import starlight.application.businessplan.required.SpellChecker; +import starlight.application.businessplan.required.SpellCheckerPort; import starlight.adapter.businessplan.webapi.dto.SubSectionCreateRequest; import starlight.application.businessplan.util.PlainTextExtractUtils; import starlight.shared.apiPayload.response.ApiResponse; @@ -22,7 +22,7 @@ public class SpellController implements SpellCheckApiDoc { private final ObjectMapper objectMapper; - private final SpellChecker spellChecker; + private final SpellCheckerPort spellChecker; @Override public ApiResponse check( diff --git a/src/main/java/starlight/adapter/expertReport/webapi/ExpertReportController.java b/src/main/java/starlight/adapter/expertReport/webapi/ExpertReportController.java index 1c400b7..2033fbf 100644 --- a/src/main/java/starlight/adapter/expertReport/webapi/ExpertReportController.java +++ b/src/main/java/starlight/adapter/expertReport/webapi/ExpertReportController.java @@ -7,7 +7,7 @@ import starlight.adapter.expertReport.webapi.dto.UpsertExpertReportRequest; import starlight.adapter.expertReport.webapi.mapper.ExpertReportMapper; import starlight.adapter.expertReport.webapi.swagger.ExpertReportApiDoc; -import starlight.application.expertReport.provided.ExpertReportServiceUseCase; +import starlight.application.expertReport.provided.ExpertReportUseCase; import starlight.application.expertReport.provided.dto.ExpertReportWithExpertResult; import starlight.domain.expertReport.entity.ExpertReport; import starlight.domain.expertReport.entity.ExpertReportComment; @@ -21,7 +21,7 @@ public class ExpertReportController implements ExpertReportApiDoc { private final ExpertReportMapper mapper; - private final ExpertReportServiceUseCase expertReportService; + private final ExpertReportUseCase expertReportService; @GetMapping public ApiResponse> getExpertReports( diff --git a/src/main/java/starlight/adapter/member/webapi/MemberController.java b/src/main/java/starlight/adapter/member/webapi/MemberController.java index bfa19d7..5bbc011 100644 --- a/src/main/java/starlight/adapter/member/webapi/MemberController.java +++ b/src/main/java/starlight/adapter/member/webapi/MemberController.java @@ -8,7 +8,7 @@ import org.springframework.web.bind.annotation.RestController; import starlight.adapter.member.webapi.swagger.MemberApiDoc; import starlight.adapter.member.webapi.dto.MemberDetailResponse; -import starlight.application.member.provided.MemberQueryUseCase; +import starlight.application.member.provided.MemberUseCase; import starlight.shared.auth.AuthenticatedMember; import starlight.shared.apiPayload.response.ApiResponse; @@ -18,7 +18,7 @@ @RequestMapping("/v1/members") public class MemberController implements MemberApiDoc { - private final MemberQueryUseCase memberQueryUseCase; + private final MemberUseCase memberQueryUseCase; @GetMapping public ApiResponse getMemberDetail( diff --git a/src/main/java/starlight/application/aireport/AiReportService.java b/src/main/java/starlight/application/aireport/AiReportService.java index bb2d7bc..5154118 100644 --- a/src/main/java/starlight/application/aireport/AiReportService.java +++ b/src/main/java/starlight/application/aireport/AiReportService.java @@ -4,89 +4,130 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import starlight.adapter.aireport.reportgrader.util.AiReportResponseParser; +import starlight.adapter.aireport.report.util.AiReportResponseParser; import starlight.application.aireport.provided.AiReportUseCase; import starlight.application.aireport.provided.dto.AiReportResult; -import starlight.application.aireport.required.AiReportGradingPort; +import starlight.application.aireport.required.AiReportCommandPort; +import starlight.application.aireport.required.ReportGraderPort; import starlight.application.aireport.required.AiReportQueryPort; import starlight.application.businessplan.provided.BusinessPlanUseCase; import starlight.application.businessplan.provided.dto.BusinessPlanResult; +import starlight.application.businessplan.required.BusinessPlanCommandPort; import starlight.application.businessplan.required.BusinessPlanQueryPort; import starlight.application.businessplan.util.BusinessPlanContentExtractor; import starlight.application.aireport.required.OcrProviderPort; -import starlight.application.infrastructure.provided.LlmGenerator; import starlight.domain.aireport.entity.AiReport; import starlight.domain.aireport.exception.AiReportErrorType; import starlight.domain.aireport.exception.AiReportException; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.enumerate.PlanStatus; +import starlight.shared.enumerate.SectionType; +import java.util.Map; import java.util.Optional; +@Slf4j @Service @RequiredArgsConstructor @Transactional -public class AiReportServiceImpl implements AiReportUseCase { - - private final BusinessPlanQueryPort businessPlanQuery; - private final BusinessPlanUseCase businessPlanService; - private final AiReportQueryPort aiReportQuery; - private final AiReportGradingPort aiReportGrader; +public class AiReportService implements AiReportUseCase { + + private final BusinessPlanCommandPort businessPlanCommandPort; + private final BusinessPlanQueryPort businessPlanQueryPort; + private final BusinessPlanUseCase businessPlanUseCase; + private final AiReportQueryPort aiReportQueryPort; + private final AiReportCommandPort aiReportCommandPort; + private final ReportGraderPort reportGrader; private final ObjectMapper objectMapper; private final OcrProviderPort ocrProvider; private final AiReportResponseParser responseParser; private final BusinessPlanContentExtractor contentExtractor; - private final LlmGenerator llmGenerator; @Override public AiReportResult gradeBusinessPlan(Long planId, Long memberId) { + log.info("사업계획서 AI 채점 시작. planId: {}, memberId: {}", planId, memberId); - BusinessPlan plan = businessPlanQuery.findByIdOrThrow(planId); + BusinessPlan plan = businessPlanQueryPort.findByIdOrThrow(planId); checkBusinessPlanOwned(plan, memberId); checkBusinessPlanWritingCompleted(plan); - AiReportResult gradingResult = aiReportGrader.gradeContent(contentExtractor.extractContent(plan)); + // 섹션별 내용 추출 + Map sectionContents = contentExtractor.extractSectionContents(plan); + log.debug("사업계획서 섹션별 내용 추출 완료. 섹션 수: {}", sectionContents.size()); + + // 전체 내용도 추출 (Supervisor용) + String fullContent = contentExtractor.extractContent(plan); + if (fullContent == null || fullContent.trim().isEmpty()) { + log.error("추출된 사업계획서 내용이 비어있습니다. planId: {}", planId); + throw new AiReportException(AiReportErrorType.AI_GRADING_FAILED); + } + + AiReportResult gradingResult = reportGrader.gradeWithSectionAgents(sectionContents, fullContent); + + // 채점 결과 검증 + if (isInvalidGradingResult(gradingResult)) { + log.error("채점 결과가 유효하지 않습니다. 모든 점수가 0이고 빈 배열입니다. planId: {}", planId); + throw new AiReportException(AiReportErrorType.AI_GRADING_FAILED); + } + + log.info("채점 완료. 총점: {}, planId: {}", gradingResult.totalScore(), planId); String rawJsonString = getRawJsonAiReportResponseFromGradingResult(gradingResult); AiReport aiReport = upsertAiReportWithRawJsonStr(rawJsonString, plan); - return responseParser.toResponse(aiReportQuery.save(aiReport)); + return responseParser.toResponse(aiReportCommandPort.save(aiReport)); } @Override public AiReportResult createAndGradePdfBusinessPlan(String title, String pdfUrl, Long memberId) { + log.info("PDF 사업계획서 생성 및 AI 채점 시작. title: {}, pdfUrl: {}, memberId: {}", title, pdfUrl, memberId); - BusinessPlanResult.Result businessPlanResult = businessPlanService.createBusinessPlanWithPdf( + BusinessPlanResult.Result businessPlanResult = businessPlanUseCase.createBusinessPlanWithPdf( title, pdfUrl, memberId ); Long businessPlanId = businessPlanResult.businessPlanId(); - BusinessPlan plan = businessPlanQuery.findByIdOrThrow(businessPlanId); + BusinessPlan plan = businessPlanQueryPort.findByIdOrThrow(businessPlanId); + log.debug("OCR 시작. pdfUrl: {}", pdfUrl); String pdfText = ocrProvider.ocrPdfTextByUrl(pdfUrl); + log.debug("OCR 완료. 텍스트 길이: {}", pdfText != null ? pdfText.length() : 0); + + if (pdfText == null || pdfText.trim().isEmpty()) { + log.error("OCR로 추출된 텍스트가 비어있습니다. pdfUrl: {}", pdfUrl); + throw new AiReportException(AiReportErrorType.AI_GRADING_FAILED); + } // PDF의 경우 기존 한 번에 LLM에 돌리는 방식을 사용 - String llmResponse = llmGenerator.generateReport(pdfText); - AiReportResult gradingResult = responseParser.parse(llmResponse); + AiReportResult gradingResult = reportGrader.gradeWithFullPrompt(pdfText); + + // 채점 결과 검증 + if (isInvalidGradingResult(gradingResult)) { + log.error("채점 결과가 유효하지 않습니다. 모든 점수가 0이고 빈 배열입니다. businessPlanId: {}", businessPlanId); + throw new AiReportException(AiReportErrorType.AI_GRADING_FAILED); + } + + log.info("PDF 채점 완료. 총점: {}, businessPlanId: {}", gradingResult.totalScore(), businessPlanId); String rawJsonString = getRawJsonAiReportResponseFromGradingResult(gradingResult); AiReport aiReport = upsertAiReportWithRawJsonStr(rawJsonString, plan); - return responseParser.toResponse(aiReportQuery.save(aiReport)); + return responseParser.toResponse(aiReportCommandPort.save(aiReport)); } @Override @Transactional(readOnly = true) public AiReportResult getAiReport(Long planId, Long memberId) { - BusinessPlan plan = businessPlanQuery.findByIdOrThrow(planId); + BusinessPlan plan = businessPlanQueryPort.findByIdOrThrow(planId); checkBusinessPlanOwned(plan, memberId); - AiReport aiReport = aiReportQuery.findByBusinessPlanId(planId) + AiReport aiReport = aiReportQueryPort.findByBusinessPlanId(planId) .orElseThrow(() -> new AiReportException(AiReportErrorType.AI_REPORT_NOT_FOUND)); return responseParser.toResponse(aiReport); @@ -104,7 +145,7 @@ private String getRawJsonAiReportResponseFromGradingResult(AiReportResult gradin } private AiReport upsertAiReportWithRawJsonStr(String rawJsonString, BusinessPlan plan) { - Optional existingReport = aiReportQuery.findByBusinessPlanId(plan.getId()); + Optional existingReport = aiReportQueryPort.findByBusinessPlanId(plan.getId()); AiReport aiReport; if (existingReport.isPresent()) { @@ -114,7 +155,7 @@ private AiReport upsertAiReportWithRawJsonStr(String rawJsonString, BusinessPlan aiReport = AiReport.create(plan.getId(), rawJsonString); } plan.updateStatus(PlanStatus.AI_REVIEWED); - businessPlanQuery.save(plan); + businessPlanCommandPort.save(plan); return aiReport; } @@ -130,4 +171,21 @@ private void checkBusinessPlanWritingCompleted(BusinessPlan plan) { throw new AiReportException(AiReportErrorType.NOT_READY_FOR_AI_REPORT); } } + + /** + * 채점 결과가 유효한지 검증 + * 모든 점수가 0이고 빈 배열인 경우 유효하지 않음 + */ + private boolean isInvalidGradingResult(AiReportResult result) { + boolean allScoresZero = (result.problemRecognitionScore() == null || result.problemRecognitionScore() == 0) && + (result.feasibilityScore() == null || result.feasibilityScore() == 0) && + (result.growthStrategyScore() == null || result.growthStrategyScore() == 0) && + (result.teamCompetenceScore() == null || result.teamCompetenceScore() == 0); + + boolean allArraysEmpty = (result.strengths() == null || result.strengths().isEmpty()) && + (result.weaknesses() == null || result.weaknesses().isEmpty()) && + (result.sectionScores() == null || result.sectionScores().isEmpty()); + + return allScoresZero && allArraysEmpty; + } } diff --git a/src/main/java/starlight/application/expertApplication/ExpertApplicationCommandService.java b/src/main/java/starlight/application/expertApplication/ExpertApplicationCommandService.java index 82e0004..da6e409 100644 --- a/src/main/java/starlight/application/expertApplication/ExpertApplicationCommandService.java +++ b/src/main/java/starlight/application/expertApplication/ExpertApplicationCommandService.java @@ -7,12 +7,12 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import starlight.application.businessplan.required.BusinessPlanQuery; +import starlight.application.businessplan.required.BusinessPlanQueryPort; import starlight.application.expertApplication.event.FeedbackRequestInput; import starlight.application.expertApplication.provided.ExpertApplicationCommandUseCase; import starlight.application.expertApplication.required.ExpertLookupPort; import starlight.application.expertApplication.required.ExpertApplicationQueryPort; -import starlight.application.expertReport.provided.ExpertReportServiceUseCase; +import starlight.application.expertReport.provided.ExpertReportUseCase; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.enumerate.PlanStatus; import starlight.domain.businessplan.exception.BusinessPlanException; @@ -32,10 +32,10 @@ public class ExpertApplicationCommandService implements ExpertApplicationCommandUseCase { private final ExpertLookupPort expertLookupPort; - private final BusinessPlanQuery planQuery; + private final BusinessPlanQueryPort planQuery; private final ExpertApplicationQueryPort applicationQueryPort; private final ApplicationEventPublisher eventPublisher; - private final ExpertReportServiceUseCase expertReportUseCase; + private final ExpertReportUseCase expertReportUseCase; private static final long MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB private static final String ALLOWED_CONTENT_TYPE = "application/pdf"; diff --git a/src/main/java/starlight/application/expertReport/ExpertReportService.java b/src/main/java/starlight/application/expertReport/ExpertReportService.java index 37d77de..4d1a546 100644 --- a/src/main/java/starlight/application/expertReport/ExpertReportService.java +++ b/src/main/java/starlight/application/expertReport/ExpertReportService.java @@ -4,8 +4,8 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import starlight.application.businessplan.required.BusinessPlanQuery; -import starlight.application.expertReport.provided.ExpertReportServiceUseCase; +import starlight.application.businessplan.required.BusinessPlanQueryPort; +import starlight.application.expertReport.provided.ExpertReportUseCase; import starlight.application.expertReport.provided.dto.ExpertReportWithExpertResult; import starlight.application.expertReport.required.ExpertApplicationCountLookupPort; import starlight.application.expertReport.required.ExpertLookupPort; @@ -29,7 +29,7 @@ @Service @RequiredArgsConstructor @Transactional -public class ExpertReportService implements ExpertReportServiceUseCase { +public class ExpertReportService implements ExpertReportUseCase { @Value("${feedback-token.token-length}") private int tokenLength; @@ -44,7 +44,7 @@ public class ExpertReportService implements ExpertReportServiceUseCase { private final ExpertReportCommandPort expertReportCommand; private final ExpertLookupPort expertLookupPort; private final ExpertApplicationCountLookupPort expertApplicationLookupPort; - private final BusinessPlanQuery businessPlanQuery; + private final BusinessPlanQueryPort businessPlanQuery; private final SecureRandom secureRandom = new SecureRandom(); @Override diff --git a/src/main/java/starlight/application/expertReport/provided/ExpertReportUseCase.java b/src/main/java/starlight/application/expertReport/provided/ExpertReportUseCase.java index 6e6e9f2..1eb15e3 100644 --- a/src/main/java/starlight/application/expertReport/provided/ExpertReportUseCase.java +++ b/src/main/java/starlight/application/expertReport/provided/ExpertReportUseCase.java @@ -7,7 +7,7 @@ import java.util.List; -public interface ExpertReportServiceUseCase{ +public interface ExpertReportUseCase { String createExpertReportLink(Long expertId, Long businessPlanId); diff --git a/src/main/java/starlight/application/member/CredentialService.java b/src/main/java/starlight/application/member/CredentialService.java index 04977d2..789073d 100644 --- a/src/main/java/starlight/application/member/CredentialService.java +++ b/src/main/java/starlight/application/member/CredentialService.java @@ -3,7 +3,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; -import starlight.application.member.provided.CredentialService; +import starlight.application.member.provided.CredentialUseCase; import starlight.domain.member.auth.exception.AuthErrorType; import starlight.domain.member.auth.exception.AuthException; import starlight.domain.member.entity.Credential; @@ -11,7 +11,7 @@ @Service @RequiredArgsConstructor -public class CredentialServiceImpl implements CredentialService { +public class CredentialService implements CredentialUseCase { private final PasswordEncoder passwordEncoder; diff --git a/src/main/java/starlight/application/member/MemberService.java b/src/main/java/starlight/application/member/MemberService.java index d635f8f..d4cb914 100644 --- a/src/main/java/starlight/application/member/MemberService.java +++ b/src/main/java/starlight/application/member/MemberService.java @@ -2,7 +2,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import starlight.application.member.provided.MemberQueryUseCase; +import starlight.application.member.provided.MemberUseCase; import starlight.application.member.required.MemberCommandPort; import starlight.application.member.required.MemberQueryPort; import starlight.domain.member.entity.Credential; @@ -13,7 +13,7 @@ @Service @RequiredArgsConstructor -public class MemberQueryService implements MemberQueryUseCase { +public class MemberService implements MemberUseCase { private final MemberQueryPort memberQueryPort; private final MemberCommandPort memberCommandPort; diff --git a/src/main/java/starlight/application/member/auth/AuthServiceImpl.java b/src/main/java/starlight/application/member/auth/AuthServiceImpl.java index e5fd1d0..346add1 100644 --- a/src/main/java/starlight/application/member/auth/AuthServiceImpl.java +++ b/src/main/java/starlight/application/member/auth/AuthServiceImpl.java @@ -11,8 +11,8 @@ import starlight.application.member.auth.provided.dto.SignUpInput; import starlight.application.member.auth.required.KeyValueMap; import starlight.application.member.auth.required.TokenProvider; -import starlight.application.member.provided.CredentialService; -import starlight.application.member.provided.MemberQueryUseCase; +import starlight.application.member.provided.CredentialUseCase; +import starlight.application.member.provided.MemberUseCase; import starlight.domain.member.auth.exception.AuthErrorType; import starlight.domain.member.auth.exception.AuthException; import starlight.domain.member.entity.Credential; @@ -25,8 +25,8 @@ @RequiredArgsConstructor public class AuthServiceImpl implements AuthUseCase { - private final MemberQueryUseCase memberQueryUseCase; - private final CredentialService credentialService; + private final MemberUseCase memberQueryUseCase; + private final CredentialUseCase credentialService; private final TokenProvider tokenProvider; private final KeyValueMap redisClient; diff --git a/src/main/java/starlight/application/member/provided/CredentialUseCase.java b/src/main/java/starlight/application/member/provided/CredentialUseCase.java index d67b5e3..f2d5051 100644 --- a/src/main/java/starlight/application/member/provided/CredentialUseCase.java +++ b/src/main/java/starlight/application/member/provided/CredentialUseCase.java @@ -3,7 +3,7 @@ import starlight.domain.member.entity.Credential; import starlight.domain.member.entity.Member; -public interface CredentialService { +public interface CredentialUseCase { Credential createCredential(String rawPassword); diff --git a/src/main/java/starlight/application/member/provided/MemberUseCase.java b/src/main/java/starlight/application/member/provided/MemberUseCase.java index 6977ddf..4f0bbb9 100644 --- a/src/main/java/starlight/application/member/provided/MemberUseCase.java +++ b/src/main/java/starlight/application/member/provided/MemberUseCase.java @@ -3,7 +3,7 @@ import starlight.domain.member.entity.Credential; import starlight.domain.member.entity.Member; -public interface MemberQueryUseCase { +public interface MemberUseCase { Member createUser(Credential credential, String name, String email, String phoneNumber); From 9be8437310112bd2f10ec81ed867df2e282aec6e Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Mon, 5 Jan 2026 22:41:52 +0900 Subject: [PATCH 08/74] =?UTF-8?q?[SRLT-124]=20Refactor:=20=EC=9D=B8?= =?UTF-8?q?=ED=94=84=EB=9D=BC=EC=8A=A4=ED=8A=B8=EB=9F=AD=EC=B2=98=20?= =?UTF-8?q?=EB=B0=8F=20=EC=84=A4=EC=A0=95=20=ED=8C=8C=EC=9D=BC=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 --- deploy/docker-compose.yaml | 2 +- .../infrastructure/ocr/ClovaOcrProvider.java | 4 +- .../storage/NcpPresignedUrlProvider.java | 4 +- .../spellcheck/DaumSpellChecker.java | 4 +- .../util/BusinessPlanContentExtractor.java | 52 +++++++++++++++++++ .../java/starlight/bootstrap/AsyncConfig.java | 14 +++++ .../aireport/exception/AiReportErrorType.java | 3 +- .../shared/enumerate/SectionType.java | 26 ++++++++-- 8 files changed, 96 insertions(+), 13 deletions(-) diff --git a/deploy/docker-compose.yaml b/deploy/docker-compose.yaml index fc86b37..6893a57 100644 --- a/deploy/docker-compose.yaml +++ b/deploy/docker-compose.yaml @@ -7,7 +7,7 @@ services: image: mysql:8.0 container_name: mysql-starLight ports: - - "3306:3306" + - "3308:3306" environment: MYSQL_ROOT_PASSWORD: "root" MYSQL_DATABASE: "starLight" 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 d36d770..5b31869 100644 --- a/src/main/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProvider.java +++ b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProvider.java @@ -9,7 +9,7 @@ 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.OcrProvider; +import starlight.application.aireport.required.OcrProviderPort; import starlight.shared.dto.infrastructure.OcrResponse; import java.util.ArrayList; @@ -18,7 +18,7 @@ @Slf4j @Service @RequiredArgsConstructor -public class ClovaOcrProvider implements OcrProvider { +public class ClovaOcrProvider implements OcrProviderPort { private static final int MAX_PAGES_PER_REQUEST = 10; diff --git a/src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java b/src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java index 9c04da3..59ffb38 100644 --- a/src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java +++ b/src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java @@ -12,7 +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.aireport.required.PresignedUrlProvider; +import starlight.application.aireport.required.PresignedUrlProviderPort; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; import java.net.URLEncoder; @@ -22,7 +22,7 @@ @Slf4j @Service @RequiredArgsConstructor -public class NcpPresignedUrlProvider implements PresignedUrlProvider { +public class NcpPresignedUrlProvider implements PresignedUrlProviderPort { private final S3Client ncpS3Client; private final S3Presigner ncpS3Presigner; diff --git a/src/main/java/starlight/adapter/businessplan/spellcheck/DaumSpellChecker.java b/src/main/java/starlight/adapter/businessplan/spellcheck/DaumSpellChecker.java index 875bd0d..82ef67c 100644 --- a/src/main/java/starlight/adapter/businessplan/spellcheck/DaumSpellChecker.java +++ b/src/main/java/starlight/adapter/businessplan/spellcheck/DaumSpellChecker.java @@ -8,7 +8,7 @@ import org.springframework.web.client.RestClient; import starlight.adapter.businessplan.spellcheck.dto.Finding; import starlight.adapter.businessplan.spellcheck.util.SpellCheckUtil; -import starlight.application.businessplan.required.SpellChecker; +import starlight.application.businessplan.required.SpellCheckerPort; import java.util.ArrayList; import java.util.Comparator; @@ -16,7 +16,7 @@ @Service @RequiredArgsConstructor -public class DaumSpellChecker implements SpellChecker { +public class DaumSpellChecker implements SpellCheckerPort { private static final int MAX_CHARS = 1000; // 요청 글자 수 제한 private static final long DAUM_MIN_INTERVAL_MS = 400L; // 호출 간 최소 간격 diff --git a/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java b/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java index 5f3e2ee..0e0732b 100644 --- a/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java +++ b/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java @@ -1,5 +1,6 @@ package starlight.application.businessplan.util; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import starlight.domain.businessplan.entity.BaseSection; import starlight.domain.businessplan.entity.BusinessPlan; @@ -8,11 +9,14 @@ import starlight.shared.enumerate.SectionType; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * BusinessPlan에서 LLM 채점을 위한 텍스트 컨텐츠를 추출하는 컴포넌트 */ +@Slf4j @Component public class BusinessPlanContentExtractor { @@ -88,5 +92,53 @@ private String extractSectionContent(BaseSection section, SectionType sectionTyp return sectionBuilder.toString(); } + + /** + * BusinessPlan에서 섹션별로 컨텐츠를 추출하여 Map으로 반환 + */ + public Map extractSectionContents(BusinessPlan businessPlan) { + Map sectionContents = new HashMap<>(); + + String problemRecognition = extractSectionContent( + businessPlan.getProblemRecognition(), + SectionType.PROBLEM_RECOGNITION, "문제 인식"); + sectionContents.put(SectionType.PROBLEM_RECOGNITION, problemRecognition); + + String feasibility = extractSectionContent( + businessPlan.getFeasibility(), + SectionType.FEASIBILITY, "실현 가능성"); + sectionContents.put(SectionType.FEASIBILITY, feasibility); + + String growthStrategy = extractSectionContent( + businessPlan.getGrowthTactic(), + SectionType.GROWTH_STRATEGY, "성장 전략"); + sectionContents.put(SectionType.GROWTH_STRATEGY, growthStrategy); + + String teamCompetence = extractSectionContent( + businessPlan.getTeamCompetence(), + SectionType.TEAM_COMPETENCE, "팀 역량"); + sectionContents.put(SectionType.TEAM_COMPETENCE, teamCompetence); + + return sectionContents; + } + + /** + * 전체 텍스트에서 섹션별로 내용을 추출 (PDF 케이스용) + * TODO: 실제 구현 필요 - 섹션 제목을 기준으로 파싱 + */ + public Map extractSectionContentsFromText(String fullContent) { + // 간단한 구현: 전체 내용을 각 섹션에 동일하게 할당 + // 나중에 실제 파싱 로직으로 개선 필요 + Map sectionContents = new HashMap<>(); + + // 섹션 제목을 찾아서 분리하는 로직 필요 + // 현재는 전체 내용을 각 섹션에 할당 + sectionContents.put(SectionType.PROBLEM_RECOGNITION, fullContent); + sectionContents.put(SectionType.FEASIBILITY, fullContent); + sectionContents.put(SectionType.GROWTH_STRATEGY, fullContent); + sectionContents.put(SectionType.TEAM_COMPETENCE, fullContent); + + return sectionContents; + } } diff --git a/src/main/java/starlight/bootstrap/AsyncConfig.java b/src/main/java/starlight/bootstrap/AsyncConfig.java index fee3c17..13ed1b2 100644 --- a/src/main/java/starlight/bootstrap/AsyncConfig.java +++ b/src/main/java/starlight/bootstrap/AsyncConfig.java @@ -32,6 +32,20 @@ public Executor emailTaskExecutor() { return executor; } + @Bean(name = "sectionGradingExecutor") + public Executor sectionGradingExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(20); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("section-grading-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(120); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } + @Override public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return (ex, method, params) -> { diff --git a/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java b/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java index 95b8599..a8386e1 100644 --- a/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java +++ b/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java @@ -12,7 +12,8 @@ public enum AiReportErrorType implements ErrorType { AI_REPORT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 AI 리포트가 존재하지 않습니다."), NOT_READY_FOR_AI_REPORT(HttpStatus.BAD_REQUEST, "사업계획서가 작성 완료되지 않아 AI 리포트를 생성할 수 없습니다."), UNAUTHORIZED_ACCESS(HttpStatus.FORBIDDEN, "권한이 없습니다."), - AI_RESPONSE_PARSING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AI 응답 파싱에 실패했습니다."); + AI_RESPONSE_PARSING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AI 응답 파싱에 실패했습니다."), + AI_GRADING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AI 채점에 실패했습니다."); ; private final HttpStatus status; diff --git a/src/main/java/starlight/shared/enumerate/SectionType.java b/src/main/java/starlight/shared/enumerate/SectionType.java index 6a4828d..6ce0432 100644 --- a/src/main/java/starlight/shared/enumerate/SectionType.java +++ b/src/main/java/starlight/shared/enumerate/SectionType.java @@ -2,16 +2,32 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; +import starlight.application.aireport.provided.dto.AiReportResult; + +import java.util.function.Function; @Getter @RequiredArgsConstructor public enum SectionType { - OVERVIEW("개요"), - PROBLEM_RECOGNITION("문제 인식"), - FEASIBILITY("실현 가능성"), - GROWTH_STRATEGY("성장 전략"), - TEAM_COMPETENCE("팀 역량"); + OVERVIEW("개요", null, null, null), + PROBLEM_RECOGNITION("문제 인식", "problem_recognition", "PROBLEM_RECOGNITION", AiReportResult::problemRecognitionScore), + FEASIBILITY("실현 가능성", "feasibility", "FEASIBILITY", AiReportResult::feasibilityScore), + GROWTH_STRATEGY("성장 전략", "growth_strategy", "GROWTH_STRATEGY", AiReportResult::growthStrategyScore), + TEAM_COMPETENCE("팀 역량", "team_competence", "TEAM_COMPETENCE", AiReportResult::teamCompetenceScore); private final String description; + private final String tag; + private final String sectionTypeString; // sectionScores에서 사용할 문자열 + private final Function scoreExtractor; // score 추출 함수 + + /** + * AiReportResponse에서 해당 섹션의 score를 추출 + */ + public Integer extractScore(AiReportResult response) { + if (scoreExtractor == null) { + return 0; + } + return scoreExtractor.apply(response); + } } \ No newline at end of file From 201d9d1ffe0fda8c300f5fae9a935f96f49c35f2 Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Mon, 5 Jan 2026 22:41:57 +0900 Subject: [PATCH 09/74] =?UTF-8?q?[SRLT-124]=20Refactor:=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=EB=A5=BC=20=EC=83=88?= =?UTF-8?q?=EB=A1=9C=EC=9A=B4=20=EA=B5=AC=EC=A1=B0=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/ai/AiChecklistGraderTest.java | 17 +-- .../adapter/ai/OpenAiReportGraderTest.java | 29 ++--- .../adapter/ai/infra/OpenAiGeneratorTest.java | 27 +++-- .../ai/util/AiReportResponseParserTest.java | 7 +- .../ImageControllerIntegrationTest.java | 4 +- .../webapi/SpellControllerTest.java | 11 +- .../AiReportServiceImplIntegrationTest.java | 114 ++++++++++++------ .../aireport/AiReportServiceImplUnitTest.java | 52 ++++---- ...usinessPlanServiceImplIntegrationTest.java | 8 +- .../BusinessPlanServiceImplUnitTest.java | 46 +++---- .../CredentialServiceImplIntegrationTest.java | 6 +- .../member/CredentialServiceImplUnitTest.java | 3 +- .../MemberQueryServiceIntegrationTest.java | 5 +- .../member/MemberQueryServiceUnitTest.java | 3 +- .../auth/AuthServiceImplIntegrationTest.java | 10 +- .../member/auth/AuthServiceImplUnitTest.java | 10 +- 16 files changed, 200 insertions(+), 152 deletions(-) diff --git a/src/test/java/starlight/adapter/ai/AiChecklistGraderTest.java b/src/test/java/starlight/adapter/ai/AiChecklistGraderTest.java index 460483f..c54eebc 100644 --- a/src/test/java/starlight/adapter/ai/AiChecklistGraderTest.java +++ b/src/test/java/starlight/adapter/ai/AiChecklistGraderTest.java @@ -2,8 +2,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import starlight.adapter.ai.infra.OpenAiGenerator; -import starlight.adapter.ai.util.ChecklistCatalog; +import starlight.adapter.businessplan.checklist.SpringAiChecklistGrader; +import starlight.adapter.aireport.report.agent.impl.SpringAiFullReportGradeAgent; +import starlight.adapter.businessplan.checklist.provider.ChecklistPromptProvider; import starlight.domain.businessplan.enumerate.SubSectionType; import java.util.List; @@ -17,17 +18,17 @@ class AiChecklistGraderTest { @Test @DisplayName("criteria별 컨텍스트를 합치고 LLM 결과를 반환") void check_returnsFromLlm() { - OpenAiGenerator generator = mock(OpenAiGenerator.class); + SpringAiFullReportGradeAgent generator = mock(SpringAiFullReportGradeAgent.class); when(generator.generateChecklistArray(any(SubSectionType.class), anyString(), anyList(), anyList())) .thenReturn(List.of(true, false, true, false, true)); - ChecklistCatalog catalog = mock(ChecklistCatalog.class); + ChecklistPromptProvider catalog = mock(ChecklistPromptProvider.class); when(catalog.getCriteriaBySubSectionType(any(SubSectionType.class))) .thenReturn(List.of("c1", "c2", "c3", "c4", "c5")); when(catalog.getDetailedCriteriaBySubSectionType(any(SubSectionType.class))) .thenReturn(List.of("d1", "d2", "d3", "d4", "d5")); - OpenAiChecklistGrader sut = new OpenAiChecklistGrader(generator, catalog); + SpringAiChecklistGrader sut = new SpringAiChecklistGrader(generator, catalog); List result = sut.check(SubSectionType.OVERVIEW_BASIC, "input text"); assertThat(result).containsExactly(true, false, true, false, true); @@ -44,17 +45,17 @@ void check_returnsFromLlm() { @Test @DisplayName("LLM 결과 길이가 5보다 짧으면 false로 패딩") void check_normalizesToFive() { - OpenAiGenerator generator = mock(OpenAiGenerator.class); + SpringAiFullReportGradeAgent generator = mock(SpringAiFullReportGradeAgent.class); when(generator.generateChecklistArray(any(SubSectionType.class), anyString(), anyList(), anyList())) .thenReturn(List.of(true)); - ChecklistCatalog catalog = mock(ChecklistCatalog.class); + ChecklistPromptProvider catalog = mock(ChecklistPromptProvider.class); when(catalog.getCriteriaBySubSectionType(any(SubSectionType.class))) .thenReturn(List.of("c1", "c2", "c3", "c4", "c5")); when(catalog.getDetailedCriteriaBySubSectionType(any(SubSectionType.class))) .thenReturn(List.of("d1", "d2", "d3", "d4", "d5")); - OpenAiChecklistGrader sut = new OpenAiChecklistGrader(generator, catalog); + SpringAiChecklistGrader sut = new SpringAiChecklistGrader(generator, catalog); List result = sut.check(SubSectionType.OVERVIEW_BASIC, "input text"); assertThat(result).containsExactly(true, false, false, false, false); } diff --git a/src/test/java/starlight/adapter/ai/OpenAiReportGraderTest.java b/src/test/java/starlight/adapter/ai/OpenAiReportGraderTest.java index c26b05d..359b48c 100644 --- a/src/test/java/starlight/adapter/ai/OpenAiReportGraderTest.java +++ b/src/test/java/starlight/adapter/ai/OpenAiReportGraderTest.java @@ -2,9 +2,10 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import starlight.adapter.ai.infra.OpenAiGenerator; -import starlight.adapter.ai.util.AiReportResponseParser; -import starlight.application.aireport.provided.dto.AiReportResponse; +import starlight.adapter.aireport.report.SpringAiReportGrader; +import starlight.adapter.aireport.report.agent.impl.SpringAiFullReportGradeAgent; +import starlight.adapter.aireport.report.util.AiReportResponseParser; +import starlight.application.aireport.provided.dto.AiReportResult; import java.util.List; @@ -41,22 +42,22 @@ void gradeContent_returnsAiReportResponse() { } """; - OpenAiGenerator generator = mock(OpenAiGenerator.class); + SpringAiFullReportGradeAgent generator = mock(SpringAiFullReportGradeAgent.class); when(generator.generateReport(content)).thenReturn(llmResponse); AiReportResponseParser parser = mock(AiReportResponseParser.class); - AiReportResponse expectedResponse = AiReportResponse.fromGradingResult( + AiReportResult expectedResponse = AiReportResult.fromGradingResult( 20, 25, 30, 20, - List.of(new AiReportResponse.SectionScoreDetailResponse("PROBLEM_RECOGNITION", "[{\"item\":\"항목1\",\"score\":5,\"maxScore\":5}]")), - List.of(new AiReportResponse.StrengthWeakness("강점1", "내용1")), - List.of(new AiReportResponse.StrengthWeakness("약점1", "내용1")) + List.of(new AiReportResult.SectionScoreDetailResponse("PROBLEM_RECOGNITION", "[{\"item\":\"항목1\",\"score\":5,\"maxScore\":5}]")), + List.of(new AiReportResult.StrengthWeakness("강점1", "내용1")), + List.of(new AiReportResult.StrengthWeakness("약점1", "내용1")) ); when(parser.parse(llmResponse)).thenReturn(expectedResponse); - OpenAiReportGrader sut = new OpenAiReportGrader(generator, parser); + SpringAiReportGrader sut = new SpringAiReportGrader(generator, parser); // when - AiReportResponse result = sut.gradeContent(content); + AiReportResult result = sut.gradeWithSectionAgents(content); // then assertThat(result).isNotNull(); @@ -80,16 +81,16 @@ void gradeContent_callsComponentsInOrder() { String content = "사업계획서 내용"; String llmResponse = "{}"; - OpenAiGenerator generator = mock(OpenAiGenerator.class); + SpringAiFullReportGradeAgent generator = mock(SpringAiFullReportGradeAgent.class); when(generator.generateReport(any())).thenReturn(llmResponse); AiReportResponseParser parser = mock(AiReportResponseParser.class); - when(parser.parse(any())).thenReturn(AiReportResponse.fromGradingResult(0, 0, 0, 0, List.of(), List.of(), List.of())); + when(parser.parse(any())).thenReturn(AiReportResult.fromGradingResult(0, 0, 0, 0, List.of(), List.of(), List.of())); - OpenAiReportGrader sut = new OpenAiReportGrader(generator, parser); + SpringAiReportGrader sut = new SpringAiReportGrader(generator, parser); // when - sut.gradeContent(content); + sut.gradeWithSectionAgents(content); // then var inOrder = inOrder(generator, parser); diff --git a/src/test/java/starlight/adapter/ai/infra/OpenAiGeneratorTest.java b/src/test/java/starlight/adapter/ai/infra/OpenAiGeneratorTest.java index 7385849..767c433 100644 --- a/src/test/java/starlight/adapter/ai/infra/OpenAiGeneratorTest.java +++ b/src/test/java/starlight/adapter/ai/infra/OpenAiGeneratorTest.java @@ -4,6 +4,9 @@ import org.junit.jupiter.api.Test; import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.prompt.Prompt; +import starlight.adapter.aireport.report.provider.SpringAiAdvisorProvider; +import starlight.adapter.aireport.report.agent.impl.SpringAiFullReportGradeAgent; +import starlight.adapter.aireport.report.provider.ReportPromptProvider; import starlight.domain.businessplan.enumerate.SubSectionType; import java.util.List; @@ -30,15 +33,15 @@ void generateChecklistArray_parsesJson() { .call() .content()).thenReturn("[true,false,true,false,true]"); - PromptProvider promptProvider = mock(PromptProvider.class); - when(promptProvider.createChecklistGradingPrompt(any(SubSectionType.class), anyString(), anyList(), anyList())) + ReportPromptProvider reportPromptProvider = mock(ReportPromptProvider.class); + when(reportPromptProvider.createChecklistGradingPrompt(any(SubSectionType.class), anyString(), anyList(), anyList())) .thenReturn(mock(Prompt.class)); - AdvisorProvider advisorProvider = mock(AdvisorProvider.class); + SpringAiAdvisorProvider advisorProvider = mock(SpringAiAdvisorProvider.class); when(advisorProvider.getSimpleLoggerAdvisor()) .thenReturn(mock(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)); - OpenAiGenerator sut = new OpenAiGenerator(builder, promptProvider, advisorProvider); + SpringAiFullReportGradeAgent sut = new SpringAiFullReportGradeAgent(builder, reportPromptProvider, advisorProvider); List result = sut.generateChecklistArray( SubSectionType.OVERVIEW_BASIC, @@ -64,15 +67,15 @@ void generateChecklistArray_parseFail_returnsAllFalse() { .call() .content()).thenReturn("not-json"); - PromptProvider promptProvider = mock(PromptProvider.class); - when(promptProvider.createChecklistGradingPrompt(any(SubSectionType.class), anyString(), anyList(), anyList())) + ReportPromptProvider reportPromptProvider = mock(ReportPromptProvider.class); + when(reportPromptProvider.createChecklistGradingPrompt(any(SubSectionType.class), anyString(), anyList(), anyList())) .thenReturn(mock(Prompt.class)); - AdvisorProvider advisorProvider = mock(AdvisorProvider.class); + SpringAiAdvisorProvider advisorProvider = mock(SpringAiAdvisorProvider.class); when(advisorProvider.getSimpleLoggerAdvisor()) .thenReturn(mock(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)); - OpenAiGenerator sut = new OpenAiGenerator(builder, promptProvider, advisorProvider); + SpringAiFullReportGradeAgent sut = new SpringAiFullReportGradeAgent(builder, reportPromptProvider, advisorProvider); List result = sut.generateChecklistArray( SubSectionType.OVERVIEW_BASIC, @@ -110,17 +113,17 @@ void generateReport_returnsString() { .call() .content()).thenReturn(expectedResponse); - PromptProvider promptProvider = mock(PromptProvider.class); - when(promptProvider.createReportGradingPrompt(anyString())) + ReportPromptProvider reportPromptProvider = mock(ReportPromptProvider.class); + when(reportPromptProvider.createReportGradingPrompt(anyString())) .thenReturn(mock(Prompt.class)); - AdvisorProvider advisorProvider = mock(AdvisorProvider.class); + SpringAiAdvisorProvider advisorProvider = mock(SpringAiAdvisorProvider.class); when(advisorProvider.getQuestionAnswerAdvisor(anyDouble(), anyInt(), any())) .thenReturn(mock(org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor.class)); when(advisorProvider.getSimpleLoggerAdvisor()) .thenReturn(mock(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)); - OpenAiGenerator sut = new OpenAiGenerator(builder, promptProvider, advisorProvider); + SpringAiFullReportGradeAgent sut = new SpringAiFullReportGradeAgent(builder, reportPromptProvider, advisorProvider); String result = sut.generateReport("test content"); diff --git a/src/test/java/starlight/adapter/ai/util/AiReportResponseParserTest.java b/src/test/java/starlight/adapter/ai/util/AiReportResponseParserTest.java index 1f5426d..2b0267e 100644 --- a/src/test/java/starlight/adapter/ai/util/AiReportResponseParserTest.java +++ b/src/test/java/starlight/adapter/ai/util/AiReportResponseParserTest.java @@ -3,7 +3,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import starlight.application.aireport.provided.dto.AiReportResponse; +import starlight.adapter.aireport.report.util.AiReportResponseParser; +import starlight.application.aireport.provided.dto.AiReportResult; import starlight.domain.aireport.exception.AiReportException; import starlight.domain.aireport.exception.AiReportErrorType; @@ -42,7 +43,7 @@ void parse_validJson_returnsResponse() { """; // when - AiReportResponse result = parser.parse(validJson); + AiReportResult result = parser.parse(validJson); // then assertThat(result).isNotNull(); @@ -127,7 +128,7 @@ void parse_textFieldResponse_parsesCorrectly() { """; // when - AiReportResponse result = parser.parse(textFieldJson); + AiReportResult result = parser.parse(textFieldJson); // then assertThat(result).isNotNull(); diff --git a/src/test/java/starlight/adapter/aireport/infrastructure/webapi/ImageControllerIntegrationTest.java b/src/test/java/starlight/adapter/aireport/infrastructure/webapi/ImageControllerIntegrationTest.java index ef1ce44..80891a0 100644 --- a/src/test/java/starlight/adapter/aireport/infrastructure/webapi/ImageControllerIntegrationTest.java +++ b/src/test/java/starlight/adapter/aireport/infrastructure/webapi/ImageControllerIntegrationTest.java @@ -14,7 +14,7 @@ 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.PresignedUrlProvider; +import starlight.application.aireport.required.PresignedUrlProviderPort; import starlight.bootstrap.SecurityConfig; import starlight.domain.member.entity.Member; import starlight.domain.member.enumerate.MemberType; @@ -43,7 +43,7 @@ class ImageControllerIntegrationTest { private MockMvc mockMvc; @MockitoBean - private PresignedUrlProvider presignedUrlProvider; + private PresignedUrlProviderPort presignedUrlProvider; @MockitoBean JpaMetamodelMappingContext jpaMetamodelMappingContext; diff --git a/src/test/java/starlight/adapter/businessplan/webapi/SpellControllerTest.java b/src/test/java/starlight/adapter/businessplan/webapi/SpellControllerTest.java index 10abb97..813147f 100644 --- a/src/test/java/starlight/adapter/businessplan/webapi/SpellControllerTest.java +++ b/src/test/java/starlight/adapter/businessplan/webapi/SpellControllerTest.java @@ -1,24 +1,19 @@ package starlight.adapter.businessplan.webapi; import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; -import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import starlight.adapter.businessplan.spellcheck.dto.Finding; -import starlight.application.businessplan.required.SpellChecker; +import starlight.application.businessplan.required.SpellCheckerPort; import java.util.List; -import java.util.Map; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) @AutoConfigureMockMvc(addFilters = false) @@ -32,8 +27,8 @@ class SpellControllerTest { @TestConfiguration static class TestBeans { @Bean - SpellChecker spellChecker() { - return new SpellChecker() { + SpellCheckerPort spellChecker() { + return new SpellCheckerPort() { @Override public List check(String sentence) { if (sentence.contains("teh")) { diff --git a/src/test/java/starlight/application/aireport/AiReportServiceImplIntegrationTest.java b/src/test/java/starlight/application/aireport/AiReportServiceImplIntegrationTest.java index 98f8b99..98df1d1 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceImplIntegrationTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceImplIntegrationTest.java @@ -10,16 +10,17 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; -import starlight.adapter.ai.util.AiReportResponseParser; +import starlight.adapter.aireport.report.util.AiReportResponseParser; import starlight.adapter.aireport.persistence.AiReportJpa; import starlight.adapter.aireport.persistence.AiReportRepository; import starlight.adapter.businessplan.persistence.BusinessPlanJpa; import starlight.adapter.businessplan.persistence.BusinessPlanRepository; -import starlight.application.aireport.provided.dto.AiReportResponse; -import starlight.application.aireport.required.AiReportGrader; -import starlight.application.aireport.required.OcrProvider; -import starlight.application.businessplan.provided.BusinessPlanService; -import starlight.application.businessplan.provided.dto.BusinessPlanResponse; +import starlight.application.aireport.provided.dto.AiReportResult; +import starlight.application.aireport.required.ReportGraderPort; +import starlight.application.aireport.required.OcrProviderPort; +import starlight.application.businessplan.provided.dto.SubSectionResult; +import starlight.application.businessplan.provided.BusinessPlanUseCase; +import starlight.application.businessplan.provided.dto.BusinessPlanResult; import starlight.application.businessplan.util.BusinessPlanContentExtractor; import starlight.domain.aireport.entity.AiReport; import starlight.domain.businessplan.entity.BusinessPlan; @@ -34,12 +35,12 @@ @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) -@Import({AiReportServiceImpl.class, AiReportJpa.class, BusinessPlanJpa.class, AiReportServiceImplIntegrationTest.TestBeans.class}) +@Import({AiReportService.class, AiReportJpa.class, BusinessPlanJpa.class, AiReportServiceImplIntegrationTest.TestBeans.class}) @DisplayName("AiReportServiceImpl 통합 테스트") class AiReportServiceImplIntegrationTest { @Autowired - AiReportServiceImpl sut; + AiReportService sut; @Autowired BusinessPlanRepository businessPlanRepository; @Autowired @@ -51,14 +52,14 @@ class AiReportServiceImplIntegrationTest { static class TestBeans { @Bean - AiReportGrader aiReportGrader() { + ReportGraderPort aiReportGrader() { return content -> { // 간단한 mock 응답 반환 - return AiReportResponse.fromGradingResult( + return AiReportResult.fromGradingResult( 20, 25, 30, 20, - List.of(new AiReportResponse.SectionScoreDetailResponse("PROBLEM_RECOGNITION", "[{\"item\":\"항목1\",\"score\":5,\"maxScore\":5}]")), - List.of(new AiReportResponse.StrengthWeakness("강점1", "내용1")), - List.of(new AiReportResponse.StrengthWeakness("약점1", "내용1")) + List.of(new AiReportResult.SectionScoreDetailResponse("PROBLEM_RECOGNITION", "[{\"item\":\"항목1\",\"score\":5,\"maxScore\":5}]")), + List.of(new AiReportResult.StrengthWeakness("강점1", "내용1")), + List.of(new AiReportResult.StrengthWeakness("약점1", "내용1")) ); }; } @@ -74,33 +75,33 @@ AiReportResponseParser responseParser() { } @Bean - BusinessPlanService businessPlanService(BusinessPlanRepository businessPlanRepository) { - return new BusinessPlanService() { + BusinessPlanUseCase businessPlanService(BusinessPlanRepository businessPlanRepository) { + return new BusinessPlanUseCase() { @Override - public starlight.application.businessplan.provided.dto.BusinessPlanResponse.PreviewPage getBusinessPlanList(Long memberId, org.springframework.data.domain.Pageable pageable) { + public BusinessPlanResult.PreviewPage getBusinessPlanList(Long memberId, org.springframework.data.domain.Pageable pageable) { throw new UnsupportedOperationException("Not implemented in test"); } @Override - public BusinessPlanResponse.Result createBusinessPlan(Long memberId) { + public BusinessPlanResult.Result createBusinessPlan(Long memberId) { BusinessPlan plan = BusinessPlan.create("default title", memberId); BusinessPlan saved = businessPlanRepository.save(plan); - return BusinessPlanResponse.Result.from(saved, "Business plan created"); + return BusinessPlanResult.Result.from(saved, "Business plan created"); } @Override - public BusinessPlanResponse.Result createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId) { + public BusinessPlanResult.Result createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId) { BusinessPlan plan = BusinessPlan.createWithPdf(title, memberId, pdfUrl); BusinessPlan saved = businessPlanRepository.save(plan); - return BusinessPlanResponse.Result.from(saved, "PDF Business plan created"); + return BusinessPlanResult.Result.from(saved, "PDF Business plan created"); } @Override - public BusinessPlanResponse.Result getBusinessPlanInfo(Long planId, Long memberId) { + public BusinessPlanResult.Result getBusinessPlanInfo(Long planId, Long memberId) { throw new UnsupportedOperationException("Not implemented in test"); } @Override - public BusinessPlanResponse.Detail getBusinessPlanDetail(Long planId, Long memberId) { + public BusinessPlanResult.Detail getBusinessPlanDetail(Long planId, Long memberId) { throw new UnsupportedOperationException("Not implemented in test"); } @@ -110,19 +111,19 @@ public String updateBusinessPlanTitle(Long planId, String title, Long memberId) } @Override - public BusinessPlanResponse.Result deleteBusinessPlan(Long planId, Long memberId) { + public BusinessPlanResult.Result deleteBusinessPlan(Long planId, Long memberId) { throw new UnsupportedOperationException("Not implemented in test"); } @Override - public starlight.application.businessplan.provided.dto.SubSectionResponse.Result upsertSubSection( + public SubSectionResult.Result upsertSubSection( Long planId, com.fasterxml.jackson.databind.JsonNode jsonNode, List checks, starlight.domain.businessplan.enumerate.SubSectionType subSectionType, Long memberId) { throw new UnsupportedOperationException("Not implemented in test"); } @Override - public starlight.application.businessplan.provided.dto.SubSectionResponse.Detail getSubSectionDetail( + public SubSectionResult.Detail getSubSectionDetail( Long planId, starlight.domain.businessplan.enumerate.SubSectionType subSectionType, Long memberId) { throw new UnsupportedOperationException("Not implemented in test"); } @@ -134,7 +135,7 @@ public List checkAndUpdateSubSection(Long planId, com.fasterxml.jackson } @Override - public starlight.application.businessplan.provided.dto.SubSectionResponse.Result deleteSubSection( + public SubSectionResult.Result deleteSubSection( Long planId, starlight.domain.businessplan.enumerate.SubSectionType subSectionType, Long memberId) { throw new UnsupportedOperationException("Not implemented in test"); } @@ -142,8 +143,8 @@ public starlight.application.businessplan.provided.dto.SubSectionResponse.Result } @Bean - OcrProvider ocrProvider() { - return new OcrProvider() { + OcrProviderPort ocrProvider() { + return new OcrProviderPort() { @Override public starlight.shared.dto.infrastructure.OcrResponse ocrPdfByUrl(String pdfUrl) { throw new UnsupportedOperationException("Not implemented in test"); @@ -160,6 +161,45 @@ public String ocrPdfTextByUrl(String pdfUrl) { BusinessPlanContentExtractor businessPlanContentExtractor() { return new BusinessPlanContentExtractor(); } + + @Bean + LlmGenerator llmGenerator() { + return new LlmGenerator() { + @Override + public java.util.List generateChecklistArray( + starlight.domain.businessplan.enumerate.SubSectionType subSectionType, + String content, + java.util.List criteria, + java.util.List detailedCriteria) { + throw new UnsupportedOperationException("Not implemented in test"); + } + + @Override + public String generateReport(String content) { + // PDF 채점용 mock JSON 응답 반환 + return """ + { + "problemRecognitionScore": 20, + "feasibilityScore": 25, + "growthStrategyScore": 30, + "teamCompetenceScore": 20, + "sectionScores": [ + { + "sectionType": "PROBLEM_RECOGNITION", + "gradingListScores": "[{\\"item\\":\\"항목1\\",\\"score\\":5,\\"maxScore\\":5}]" + } + ], + "strengths": [ + {"title": "강점1", "content": "내용1"} + ], + "weaknesses": [ + {"title": "약점1", "content": "내용1"} + ] + } + """; + } + }; + } } /** @@ -213,7 +253,7 @@ void gradeBusinessPlan_createsNewReport() { Long planId = plan.getId(); // when - AiReportResponse result = sut.gradeBusinessPlan(planId, memberId); + AiReportResult result = sut.gradeBusinessPlan(planId, memberId); // then assertThat(result).isNotNull(); @@ -252,12 +292,12 @@ void gradeBusinessPlan_updatesExistingReport() { Long planId = plan.getId(); // 첫 번째 채점 - AiReportResponse firstResult = sut.gradeBusinessPlan(planId, memberId); + AiReportResult firstResult = sut.gradeBusinessPlan(planId, memberId); em.flush(); em.clear(); // 두 번째 채점 (업데이트) - AiReportResponse secondResult = sut.gradeBusinessPlan(planId, memberId); + AiReportResult secondResult = sut.gradeBusinessPlan(planId, memberId); // then assertThat(secondResult).isNotNull(); @@ -288,7 +328,7 @@ void getAiReport_returnsResponse() { em.clear(); // when - AiReportResponse result = sut.getAiReport(planId, memberId); + AiReportResult result = sut.getAiReport(planId, memberId); // then assertThat(result).isNotNull(); @@ -314,12 +354,12 @@ void convertToJsonNode_and_toResponse_workCorrectly() { Long planId = plan.getId(); // 채점하여 리포트 생성 - AiReportResponse gradingResult = sut.gradeBusinessPlan(planId, memberId); + AiReportResult gradingResult = sut.gradeBusinessPlan(planId, memberId); em.flush(); em.clear(); // when - 조회 - AiReportResponse retrievedResult = sut.getAiReport(planId, memberId); + AiReportResult retrievedResult = sut.getAiReport(planId, memberId); // then - 저장된 데이터와 조회된 데이터가 일치하는지 확인 assertThat(retrievedResult.problemRecognitionScore()).isEqualTo(gradingResult.problemRecognitionScore()); @@ -341,7 +381,7 @@ void createAndGradePdfBusinessPlan_createsBusinessPlanAndReport() { String pdfUrl = "https://example.com/test.pdf"; // when - AiReportResponse result = sut.createAndGradePdfBusinessPlan(title, pdfUrl, memberId); + AiReportResult result = sut.createAndGradePdfBusinessPlan(title, pdfUrl, memberId); // then assertThat(result).isNotNull(); @@ -378,13 +418,13 @@ void createAndGradePdfBusinessPlan_canRetrieveReport() { String pdfUrl = "https://example.com/test.pdf"; // when - PDF로 사업계획서 생성 및 채점 - AiReportResponse createdResult = sut.createAndGradePdfBusinessPlan(title, pdfUrl, memberId); + AiReportResult createdResult = sut.createAndGradePdfBusinessPlan(title, pdfUrl, memberId); Long planId = createdResult.businessPlanId(); em.flush(); em.clear(); // when - 리포트 조회 - AiReportResponse retrievedResult = sut.getAiReport(planId, memberId); + AiReportResult retrievedResult = sut.getAiReport(planId, memberId); // then assertThat(retrievedResult).isNotNull(); diff --git a/src/test/java/starlight/application/aireport/AiReportServiceImplUnitTest.java b/src/test/java/starlight/application/aireport/AiReportServiceImplUnitTest.java index 64a4cd9..3fb4c9c 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceImplUnitTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceImplUnitTest.java @@ -3,13 +3,13 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import starlight.adapter.ai.util.AiReportResponseParser; -import starlight.application.aireport.provided.dto.AiReportResponse; -import starlight.application.aireport.required.AiReportGrader; -import starlight.application.aireport.required.AiReportQuery; -import starlight.application.aireport.required.OcrProvider; -import starlight.application.businessplan.provided.BusinessPlanService; -import starlight.application.businessplan.required.BusinessPlanQuery; +import starlight.adapter.aireport.report.util.AiReportResponseParser; +import starlight.application.aireport.provided.dto.AiReportResult; +import starlight.application.aireport.required.ReportGraderPort; +import starlight.application.aireport.required.AiReportQueryPort; +import starlight.application.aireport.required.OcrProviderPort; +import starlight.application.businessplan.provided.BusinessPlanUseCase; +import starlight.application.businessplan.required.BusinessPlanQueryPort; import starlight.application.businessplan.util.BusinessPlanContentExtractor; import starlight.domain.aireport.entity.AiReport; import starlight.domain.aireport.exception.AiReportErrorType; @@ -29,16 +29,16 @@ @DisplayName("AiReportServiceImpl 유닛 테스트") class AiReportServiceImplUnitTest { - private final BusinessPlanQuery businessPlanQuery = mock(BusinessPlanQuery.class); - private final BusinessPlanService businessPlanService = mock(BusinessPlanService.class); - private final AiReportQuery aiReportQuery = mock(AiReportQuery.class); - private final AiReportGrader aiReportGrader = mock(AiReportGrader.class); + private final BusinessPlanQueryPort businessPlanQuery = mock(BusinessPlanQueryPort.class); + private final BusinessPlanUseCase businessPlanService = mock(BusinessPlanUseCase.class); + private final AiReportQueryPort aiReportQuery = mock(AiReportQueryPort.class); + private final ReportGraderPort aiReportGrader = mock(ReportGraderPort.class); private final ObjectMapper objectMapper = new ObjectMapper(); - private final OcrProvider ocrProvider = mock(OcrProvider.class); + private final OcrProviderPort ocrProvider = mock(OcrProviderPort.class); private final AiReportResponseParser responseParser = new AiReportResponseParser(objectMapper); private final BusinessPlanContentExtractor contentExtractor = mock(BusinessPlanContentExtractor.class); - private AiReportServiceImpl sut; + private AiReportService sut; @Test @DisplayName("채점 성공 시 새로운 AiReport를 생성하고 저장한다") @@ -56,13 +56,13 @@ void gradeBusinessPlan_createsNewReport() { String extractedContent = "사업계획서 내용"; when(contentExtractor.extractContent(plan)).thenReturn(extractedContent); - AiReportResponse gradingResult = AiReportResponse.fromGradingResult( + AiReportResult gradingResult = AiReportResult.fromGradingResult( 20, 25, 30, 20, List.of(), List.of(), List.of() ); - when(aiReportGrader.gradeContent(extractedContent)).thenReturn(gradingResult); + when(aiReportGrader.gradeWithSectionAgents(extractedContent)).thenReturn(gradingResult); String rawJson = """ { @@ -81,10 +81,10 @@ void gradeBusinessPlan_createsNewReport() { when(savedReport.getRawJson()).thenReturn(RawJson.create(rawJson)); when(aiReportQuery.save(any(AiReport.class))).thenReturn(savedReport); - sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when - AiReportResponse result = sut.gradeBusinessPlan(planId, memberId); + AiReportResult result = sut.gradeBusinessPlan(planId, memberId); // then assertThat(result).isNotNull(); @@ -110,13 +110,13 @@ void gradeBusinessPlan_updatesExistingReport() { String extractedContent = "사업계획서 내용"; when(contentExtractor.extractContent(plan)).thenReturn(extractedContent); - AiReportResponse gradingResult = AiReportResponse.fromGradingResult( + AiReportResult gradingResult = AiReportResult.fromGradingResult( 20, 25, 30, 20, List.of(), List.of(), List.of() ); - when(aiReportGrader.gradeContent(extractedContent)).thenReturn(gradingResult); + when(aiReportGrader.gradeWithSectionAgents(extractedContent)).thenReturn(gradingResult); String rawJson = """ { @@ -134,10 +134,10 @@ void gradeBusinessPlan_updatesExistingReport() { when(existingReport.getRawJson()).thenReturn(RawJson.create(rawJson)); when(aiReportQuery.save(existingReport)).thenReturn(existingReport); - sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when - AiReportResponse result = sut.gradeBusinessPlan(planId, memberId); + AiReportResult result = sut.gradeBusinessPlan(planId, memberId); // then assertThat(result).isNotNull(); @@ -156,7 +156,7 @@ void gradeBusinessPlan_throwsExceptionWhenNotOwner() { when(plan.isOwnedBy(memberId)).thenReturn(false); when(businessPlanQuery.findByIdOrThrow(planId)).thenReturn(plan); - sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) @@ -176,7 +176,7 @@ void gradeBusinessPlan_throwsExceptionWhenNotCompleted() { when(plan.areWritingCompleted()).thenReturn(false); when(businessPlanQuery.findByIdOrThrow(planId)).thenReturn(plan); - sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) @@ -214,10 +214,10 @@ void getAiReport_returnsResponse() { when(aiReport.getRawJson()).thenReturn(RawJson.create(rawJson)); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.of(aiReport)); - sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when - AiReportResponse result = sut.getAiReport(planId, memberId); + AiReportResult result = sut.getAiReport(planId, memberId); // then assertThat(result).isNotNull(); @@ -238,7 +238,7 @@ void getAiReport_throwsExceptionWhenNotFound() { when(businessPlanQuery.findByIdOrThrow(planId)).thenReturn(plan); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.empty()); - sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.getAiReport(planId, memberId)) diff --git a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java index 7c076ef..34f6772 100644 --- a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java +++ b/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java @@ -11,7 +11,7 @@ import org.springframework.context.annotation.Import; import starlight.adapter.businessplan.persistence.BusinessPlanJpa; import starlight.adapter.businessplan.persistence.BusinessPlanRepository; -import starlight.application.businessplan.required.ChecklistGrader; +import starlight.application.businessplan.required.ChecklistGraderPort; import starlight.application.member.required.MemberQueryPort; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.entity.SubSection; @@ -26,12 +26,12 @@ @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) -@Import({ BusinessPlanServiceImpl.class, BusinessPlanJpa.class, +@Import({ BusinessPlanService.class, BusinessPlanJpa.class, BusinessPlanServiceImplIntegrationTest.TestBeans.class }) class BusinessPlanServiceImplIntegrationTest { @Autowired - BusinessPlanServiceImpl sut; + BusinessPlanService sut; @Autowired BusinessPlanRepository businessPlanRepository; @Autowired @@ -40,7 +40,7 @@ class BusinessPlanServiceImplIntegrationTest { @TestConfiguration static class TestBeans { @Bean - ChecklistGrader checklistGrader() { + ChecklistGraderPort checklistGrader() { return (subSectionType, content) -> List.of(false, false, false, false, false); } diff --git a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java index fcc2707..c9a4bd8 100644 --- a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java +++ b/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java @@ -9,10 +9,10 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import starlight.application.businessplan.provided.dto.BusinessPlanResponse; -import starlight.application.businessplan.provided.dto.SubSectionResponse; -import starlight.application.businessplan.required.BusinessPlanQuery; -import starlight.application.businessplan.required.ChecklistGrader; +import starlight.application.businessplan.provided.dto.BusinessPlanResult; +import starlight.application.businessplan.provided.dto.SubSectionResult; +import starlight.application.businessplan.required.BusinessPlanQueryPort; +import starlight.application.businessplan.required.ChecklistGraderPort; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.entity.Overview; import starlight.domain.businessplan.entity.SubSection; @@ -37,10 +37,10 @@ class BusinessPlanServiceImplUnitTest { @Mock - private BusinessPlanQuery businessPlanQuery; + private BusinessPlanQueryPort businessPlanQuery; @Mock - private ChecklistGrader checklistGrader; + private ChecklistGraderPort checklistGrader; @Mock private ObjectMapper objectMapper; @@ -49,7 +49,7 @@ class BusinessPlanServiceImplUnitTest { private MemberQueryPort memberQuery; @InjectMocks - private BusinessPlanServiceImpl sut; + private BusinessPlanService sut; private BusinessPlan buildPlanWithSections(Long memberId) { return BusinessPlan.create("default title", memberId); @@ -75,7 +75,7 @@ void createBusinessPlan_savesRoot() { when(businessPlanQuery.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); - BusinessPlanResponse.Result created = sut.createBusinessPlan(1L); + BusinessPlanResult.Result created = sut.createBusinessPlan(1L); assertThat(created).isNotNull(); assertThat(created.message()).isEqualTo("Business plan created"); @@ -92,7 +92,7 @@ void createBusinessPlanWithPdf_savesRoot() { String pdfUrl = "https://example.com/test.pdf"; Long memberId = 1L; - BusinessPlanResponse.Result created = sut.createBusinessPlanWithPdf(title, pdfUrl, memberId); + BusinessPlanResult.Result created = sut.createBusinessPlanWithPdf(title, pdfUrl, memberId); assertThat(created).isNotNull(); assertThat(created.message()).isEqualTo("PDF Business plan created"); @@ -134,7 +134,7 @@ void deleteBusinessPlan_cascadeDeletesSubSections() { when(plan.getId()).thenReturn(100L); when(businessPlanQuery.findByIdOrThrow(100L)).thenReturn(plan); - BusinessPlanResponse.Result deleted = sut.deleteBusinessPlan(100L, 10L); + BusinessPlanResult.Result deleted = sut.deleteBusinessPlan(100L, 10L); assertThat(deleted).isNotNull(); assertThat(deleted.businessPlanId()).isEqualTo(100L); @@ -165,7 +165,7 @@ void upsertSubSection_creates_whenNotExists() { // when List checks = List.of(false, false, false, false, false); - SubSectionResponse.Result res = sut.upsertSubSection(1L, jsonNode, checks, + SubSectionResult.Result res = sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.OVERVIEW_BASIC, 10L); // then @@ -203,7 +203,7 @@ void upsertSubSection_updates_whenExists() { } List checks = List.of(false, false, false, false, false); - SubSectionResponse.Result res = sut.upsertSubSection(1L, jsonNode, checks, + SubSectionResult.Result res = sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.OVERVIEW_BASIC, 10L); assertThat(res.message()).isEqualTo("Subsection updated"); @@ -238,7 +238,7 @@ void getSubSectionDetail_returnsContent() { when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); - SubSectionResponse.Detail detail = sut.getSubSectionDetail(1L, SubSectionType.OVERVIEW_BASIC, 10L); + SubSectionResult.Detail detail = sut.getSubSectionDetail(1L, SubSectionType.OVERVIEW_BASIC, 10L); assertThat(detail).isNotNull(); assertThat(detail.subSectionType()).isEqualTo(SubSectionType.OVERVIEW_BASIC); @@ -280,7 +280,7 @@ void deleteSubSection_success() { when(businessPlanQuery.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); - SubSectionResponse.Result res = sut.deleteSubSection(1L, SubSectionType.OVERVIEW_BASIC, 10L); + SubSectionResult.Result res = sut.deleteSubSection(1L, SubSectionType.OVERVIEW_BASIC, 10L); assertThat(res).isNotNull(); assertThat(res.subSectionType()).isEqualTo(SubSectionType.OVERVIEW_BASIC); @@ -311,7 +311,7 @@ void getBusinessPlanList_returnsPreviewPage() { .thenReturn(new PageImpl<>(List.of(plan), pageable, 7)); // when - BusinessPlanResponse.PreviewPage res = sut.getBusinessPlanList(1L, pageable); + BusinessPlanResult.PreviewPage res = sut.getBusinessPlanList(1L, pageable); // then assertThat(res.totalElements()).isEqualTo(7); @@ -337,14 +337,14 @@ void getBusinessPlanSubSections_returnsExistingSubSectionList() { List.of(false, false, false, false, false)); plan.getProblemRecognition().putSubSection(problem); - when(businessPlanQuery.getOrThrowWithAllSubSections(1L)).thenReturn(plan); + when(businessPlanQuery.findWithAllSubSectionsOrThrow(1L)).thenReturn(plan); - BusinessPlanResponse.Detail detail = sut.getBusinessPlanDetail(1L, 10L); + BusinessPlanResult.Detail detail = sut.getBusinessPlanDetail(1L, 10L); assertThat(detail.title()).isEqualTo(plan.getTitle()); assertThat(detail.subSectionDetailList()).hasSize(2); assertThat(detail.subSectionDetailList()) - .extracting(SubSectionResponse.Detail::subSectionType) + .extracting(SubSectionResult.Detail::subSectionType) .containsExactly(SubSectionType.OVERVIEW_BASIC, SubSectionType.PROBLEM_BACKGROUND); assertThat(detail.subSectionDetailList().get(0).content().path("text").asText()).isEqualTo("overview"); assertThat(detail.subSectionDetailList().get(1).content().path("text").asText()).isEqualTo("problem"); @@ -355,7 +355,7 @@ void getBusinessPlanSubSections_returnsExistingSubSectionList() { void getBusinessPlanDetail_unauthorized_throws() { BusinessPlan plan = mock(BusinessPlan.class); when(plan.isOwnedBy(10L)).thenReturn(false); - when(businessPlanQuery.getOrThrowWithAllSubSections(1L)).thenReturn(plan); + when(businessPlanQuery.findWithAllSubSectionsOrThrow(1L)).thenReturn(plan); org.junit.jupiter.api.Assertions.assertThrows(BusinessPlanException.class, () -> sut.getBusinessPlanDetail(1L, 10L)); @@ -448,13 +448,13 @@ void createSubSection_forEachSectionType() { } List checks = List.of(false, false, false, false, false); - SubSectionResponse.Result r1 = sut.upsertSubSection(1L, jsonNode, checks, + SubSectionResult.Result r1 = sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.PROBLEM_BACKGROUND, 10L); - SubSectionResponse.Result r2 = sut.upsertSubSection(1L, jsonNode, checks, + SubSectionResult.Result r2 = sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.FEASIBILITY_STRATEGY, 10L); - SubSectionResponse.Result r3 = sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.GROWTH_MODEL, + SubSectionResult.Result r3 = sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.GROWTH_MODEL, 10L); - SubSectionResponse.Result r4 = sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.TEAM_FOUNDER, + SubSectionResult.Result r4 = sut.upsertSubSection(1L, jsonNode, checks, SubSectionType.TEAM_FOUNDER, 10L); assertThat(r1.message()).isEqualTo("Subsection created"); diff --git a/src/test/java/starlight/application/member/CredentialServiceImplIntegrationTest.java b/src/test/java/starlight/application/member/CredentialServiceImplIntegrationTest.java index 2cd7ba8..336f57b 100644 --- a/src/test/java/starlight/application/member/CredentialServiceImplIntegrationTest.java +++ b/src/test/java/starlight/application/member/CredentialServiceImplIntegrationTest.java @@ -5,7 +5,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Import; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.ContextConfiguration; @@ -18,7 +17,7 @@ import static org.mockito.Mockito.when; @ExtendWith(SpringExtension.class) -@ContextConfiguration(classes = {CredentialServiceImpl.class, CredentialServiceImplIntegrationTest.TestBeans.class}) +@ContextConfiguration(classes = {CredentialService.class, CredentialServiceImplIntegrationTest.TestBeans.class}) class CredentialServiceImplIntegrationTest { @TestConfiguration @@ -26,7 +25,8 @@ static class TestBeans { @Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } } - @Autowired CredentialServiceImpl sut; + @Autowired + CredentialService sut; @Autowired PasswordEncoder passwordEncoder; @Test diff --git a/src/test/java/starlight/application/member/CredentialServiceImplUnitTest.java b/src/test/java/starlight/application/member/CredentialServiceImplUnitTest.java index 91a936a..2ddc291 100644 --- a/src/test/java/starlight/application/member/CredentialServiceImplUnitTest.java +++ b/src/test/java/starlight/application/member/CredentialServiceImplUnitTest.java @@ -17,7 +17,8 @@ class CredentialServiceImplUnitTest { @Mock PasswordEncoder passwordEncoder; - @InjectMocks CredentialServiceImpl sut; + @InjectMocks + CredentialService sut; @Test void createCredential_정상_해싱후_저장() { diff --git a/src/test/java/starlight/application/member/MemberQueryServiceIntegrationTest.java b/src/test/java/starlight/application/member/MemberQueryServiceIntegrationTest.java index 8a53170..f24664c 100644 --- a/src/test/java/starlight/application/member/MemberQueryServiceIntegrationTest.java +++ b/src/test/java/starlight/application/member/MemberQueryServiceIntegrationTest.java @@ -18,10 +18,11 @@ @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) -@Import({MemberQueryService.class, MemberJpa.class}) +@Import({MemberService.class, MemberJpa.class}) class MemberQueryServiceIntegrationTest { - @Autowired MemberQueryService sut; + @Autowired + MemberService sut; @Autowired MemberRepository memberRepository; @Test diff --git a/src/test/java/starlight/application/member/MemberQueryServiceUnitTest.java b/src/test/java/starlight/application/member/MemberQueryServiceUnitTest.java index dcd8009..c2ee2cf 100644 --- a/src/test/java/starlight/application/member/MemberQueryServiceUnitTest.java +++ b/src/test/java/starlight/application/member/MemberQueryServiceUnitTest.java @@ -22,7 +22,8 @@ class MemberQueryServiceUnitTest { @Mock MemberQueryPort memberQueryPort; @Mock MemberCommandPort memberCommandPort; - @InjectMocks MemberQueryService sut; + @InjectMocks + MemberService sut; @Test void createUser_중복이메일이면_예외() { diff --git a/src/test/java/starlight/application/member/auth/AuthServiceImplIntegrationTest.java b/src/test/java/starlight/application/member/auth/AuthServiceImplIntegrationTest.java index d13032a..36c868e 100644 --- a/src/test/java/starlight/application/member/auth/AuthServiceImplIntegrationTest.java +++ b/src/test/java/starlight/application/member/auth/AuthServiceImplIntegrationTest.java @@ -11,8 +11,8 @@ import starlight.application.member.auth.provided.dto.SignUpInput; import starlight.application.member.auth.required.KeyValueMap; import starlight.application.member.auth.required.TokenProvider; -import starlight.application.member.provided.CredentialService; -import starlight.application.member.provided.MemberQueryUseCase; +import starlight.application.member.provided.CredentialUseCase; +import starlight.application.member.provided.MemberUseCase; import starlight.domain.member.auth.exception.AuthException; import starlight.domain.member.entity.Credential; import starlight.domain.member.entity.Member; @@ -28,8 +28,10 @@ }) class AuthServiceImplIntegrationTest { - @MockitoBean MemberQueryUseCase memberQueryUseCase; - @MockitoBean CredentialService credentialService; + @MockitoBean + MemberUseCase memberQueryUseCase; + @MockitoBean + CredentialUseCase credentialService; @MockitoBean TokenProvider tokenProvider; @MockitoBean KeyValueMap redisClient; diff --git a/src/test/java/starlight/application/member/auth/AuthServiceImplUnitTest.java b/src/test/java/starlight/application/member/auth/AuthServiceImplUnitTest.java index d80e9af..aa4d8a7 100644 --- a/src/test/java/starlight/application/member/auth/AuthServiceImplUnitTest.java +++ b/src/test/java/starlight/application/member/auth/AuthServiceImplUnitTest.java @@ -11,8 +11,8 @@ import starlight.application.member.auth.provided.dto.SignInInput; import starlight.application.member.auth.required.KeyValueMap; import starlight.application.member.auth.required.TokenProvider; -import starlight.application.member.provided.CredentialService; -import starlight.application.member.provided.MemberQueryUseCase; +import starlight.application.member.provided.CredentialUseCase; +import starlight.application.member.provided.MemberUseCase; import starlight.domain.member.auth.exception.AuthException; import starlight.domain.member.entity.Member; import starlight.domain.member.enumerate.MemberType; @@ -24,8 +24,10 @@ @ExtendWith(MockitoExtension.class) class AuthServiceImplUnitTest { - @Mock MemberQueryUseCase memberQueryUseCase; - @Mock CredentialService credentialService; + @Mock + MemberUseCase memberQueryUseCase; + @Mock + CredentialUseCase credentialService; @Mock TokenProvider tokenProvider; @Mock KeyValueMap redisClient; From 8dc5161805132c818d08393b1de13725a99abb7d Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Mon, 5 Jan 2026 22:43:29 +0900 Subject: [PATCH 10/74] =?UTF-8?q?[SRLT-124]=20Refactor:=20docker-compose?= =?UTF-8?q?=20MySQL=20=ED=8F=AC=ED=8A=B8=20=EB=A1=A4=EB=B0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/docker-compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deploy/docker-compose.yaml b/deploy/docker-compose.yaml index 6893a57..fc86b37 100644 --- a/deploy/docker-compose.yaml +++ b/deploy/docker-compose.yaml @@ -7,7 +7,7 @@ services: image: mysql:8.0 container_name: mysql-starLight ports: - - "3308:3306" + - "3306:3306" environment: MYSQL_ROOT_PASSWORD: "root" MYSQL_DATABASE: "starLight" From dd51a8bb2520972f7c7fe079b614e0f69010eac6 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Wed, 7 Jan 2026 19:24:23 +0900 Subject: [PATCH 11/74] =?UTF-8?q?[SRLT-111]=20Chore:=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=EC=97=94=EB=93=9C=20=EC=8A=A4=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=A7=95=20=ED=8D=BC=EB=B8=94=EB=A6=AC=EC=8B=9C=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=EB=A5=BC=20=EB=B3=80=EA=B2=BD=ED=95=9C=EB=8B=A4?= 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 7983bf3..8f217f0 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 7983bf3050cb3c027d4cac2c342e24378fe6a2fe +Subproject commit 8f217f0839a91b9e7b3fca55c90d304ca2056707 From a2f854cf5be65a43a597d03d954b14e5f385aa8c Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Wed, 7 Jan 2026 19:24:49 +0900 Subject: [PATCH 12/74] =?UTF-8?q?[SRLT-111]=20Chore:=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=EC=97=94=EB=93=9C=20=EC=8A=A4=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=A7=95=20=ED=8D=BC=EB=B8=94=EB=A6=AC=EC=8B=9C=20=EC=A3=BC?= =?UTF-8?q?=EC=86=8C=EB=A5=BC=20=EB=B3=80=EA=B2=BD=ED=95=9C=EB=8B=A4?= 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 8f217f0..dec3e50 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 8f217f0839a91b9e7b3fca55c90d304ca2056707 +Subproject commit dec3e50ed2b7d7e3e9575655f353663d6603183b From 2157ceed60aed6b95dfdef55e973dc46c8d6a751 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Wed, 7 Jan 2026 19:39:00 +0900 Subject: [PATCH 13/74] =?UTF-8?q?[SRLT-111]=20Chore:=20=EC=9D=B4=EC=A0=84?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EB=8F=8C?= =?UTF-8?q?=EB=A6=B0=EB=8B=A4?= 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 dec3e50..1d8d33f 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit dec3e50ed2b7d7e3e9575655f353663d6603183b +Subproject commit 1d8d33ff2c629b8052cb92b8cc2398818546bec0 From 0e7b80de8b2a5b116514691a3b57d3983001e28a Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Wed, 7 Jan 2026 21:10:12 +0900 Subject: [PATCH 14/74] =?UTF-8?q?[SRLT-129]=20Chore:=20=EC=9A=94=EA=B8=88?= =?UTF-8?q?=EC=A0=9C=20=EA=B0=80=EA=B2=A9=EC=9D=84=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EB=AA=A8=EC=85=98=20=EA=B0=80=EA=B2=A9=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../starlight/domain/order/enumerate/UsageProductType.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/starlight/domain/order/enumerate/UsageProductType.java b/src/main/java/starlight/domain/order/enumerate/UsageProductType.java index 2c857d2..274a199 100644 --- a/src/main/java/starlight/domain/order/enumerate/UsageProductType.java +++ b/src/main/java/starlight/domain/order/enumerate/UsageProductType.java @@ -9,8 +9,8 @@ @RequiredArgsConstructor public enum UsageProductType { - AI_REPORT_1("AI_REPORT_1", 1, 49_000L, "LITE 요금제"), - AI_REPORT_2("AI_REPORT_2", 2, 89_000L, "STANDARD 요금제"); + AI_REPORT_1("AI_REPORT_1", 1, 0L, "LITE 요금제"), + AI_REPORT_2("AI_REPORT_2", 2, 0L, "STANDARD 요금제"); private final String code; // 상품 코드 private final int usageCount; // 사용 가능 횟수 From 2fddbfff8b67bf7bdc2ac5e4e2d74533181c0443 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Wed, 7 Jan 2026 21:15:01 +0900 Subject: [PATCH 15/74] =?UTF-8?q?[SRLT-129]=20Chore:=20Cors=EC=97=90=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=20=EC=A3=BC=EC=86=8C=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/starlight/bootstrap/SecurityConfig.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/starlight/bootstrap/SecurityConfig.java b/src/main/java/starlight/bootstrap/SecurityConfig.java index 50b0eaa..dd30857 100644 --- a/src/main/java/starlight/bootstrap/SecurityConfig.java +++ b/src/main/java/starlight/bootstrap/SecurityConfig.java @@ -39,6 +39,7 @@ public class SecurityConfig { @Value("${cors.origin.server}") String ServerBaseUrl; @Value("${cors.origin.client}") String clientBaseUrl; + @Value("${cors.origin.develop}") String devBaseUrl; private final JwtFilter jwtFilter; private final ExceptionFilter exceptionFilter; @@ -100,7 +101,8 @@ public CorsConfigurationSource corsConfigurationSource() { configuration.setAllowedOrigins(List.of( clientBaseUrl, - ServerBaseUrl + ServerBaseUrl, + devBaseUrl )); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); From b6890b6aaa90e1278681a4c72620f2e701aff66c Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Wed, 7 Jan 2026 21:15:04 +0900 Subject: [PATCH 16/74] =?UTF-8?q?[SRLT-129]=20Chore:=20Cors=EC=97=90=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=20=EC=A3=BC=EC=86=8C=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= 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 1d8d33f..229ee0d 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 1d8d33ff2c629b8052cb92b8cc2398818546bec0 +Subproject commit 229ee0d573ab2eb4c9f5776b59344def8629b3dc From 2eb78dfb70ec965216c63d581d57b64d121e8b3c Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Wed, 7 Jan 2026 21:28:12 +0900 Subject: [PATCH 17/74] =?UTF-8?q?[SRLT-129]=20Chore:=20=EA=B0=80=EA=B2=A9?= =?UTF-8?q?=EC=9D=84=20=EB=90=98=EB=8F=8C=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../starlight/domain/order/enumerate/UsageProductType.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/starlight/domain/order/enumerate/UsageProductType.java b/src/main/java/starlight/domain/order/enumerate/UsageProductType.java index 274a199..2c857d2 100644 --- a/src/main/java/starlight/domain/order/enumerate/UsageProductType.java +++ b/src/main/java/starlight/domain/order/enumerate/UsageProductType.java @@ -9,8 +9,8 @@ @RequiredArgsConstructor public enum UsageProductType { - AI_REPORT_1("AI_REPORT_1", 1, 0L, "LITE 요금제"), - AI_REPORT_2("AI_REPORT_2", 2, 0L, "STANDARD 요금제"); + AI_REPORT_1("AI_REPORT_1", 1, 49_000L, "LITE 요금제"), + AI_REPORT_2("AI_REPORT_2", 2, 89_000L, "STANDARD 요금제"); private final String code; // 상품 코드 private final int usageCount; // 사용 가능 횟수 From e83f14af24d10350c4c04cb730c1c1d40b064975 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Wed, 7 Jan 2026 21:33:59 +0900 Subject: [PATCH 18/74] =?UTF-8?q?[SRLT-129]=20test:=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EB=82=B4=EC=9A=A9=EC=9D=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EC=84=A4=EC=A0=95=EC=97=90=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= 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 229ee0d..f99b539 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 229ee0d573ab2eb4c9f5776b59344def8629b3dc +Subproject commit f99b5394463d91b5c31a7907dd48c81e1760ee61 From b6b3cd1a5a0b4f3de7c7190cf633fef1aa7cd5ec Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Fri, 9 Jan 2026 08:34:33 -0600 Subject: [PATCH 19/74] =?UTF-8?q?[SRLT-124]=20Chore:=20=EB=A9=94=EC=86=8C?= =?UTF-8?q?=EB=93=9C=20=EA=B0=9C=ED=96=89=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/aireport/required/AiReportCommandPort.java | 1 + .../application/aireport/required/AiReportQueryPort.java | 1 + .../application/aireport/required/ReportGraderPort.java | 2 ++ .../aireport}/util/AiReportResponseParser.java | 5 +---- 4 files changed, 5 insertions(+), 4 deletions(-) rename src/main/java/starlight/{adapter/aireport/report => application/aireport}/util/AiReportResponseParser.java (99%) diff --git a/src/main/java/starlight/application/aireport/required/AiReportCommandPort.java b/src/main/java/starlight/application/aireport/required/AiReportCommandPort.java index 564958d..5acf707 100644 --- a/src/main/java/starlight/application/aireport/required/AiReportCommandPort.java +++ b/src/main/java/starlight/application/aireport/required/AiReportCommandPort.java @@ -3,5 +3,6 @@ import starlight.domain.aireport.entity.AiReport; public interface AiReportCommandPort { + AiReport save(AiReport aiReport); } diff --git a/src/main/java/starlight/application/aireport/required/AiReportQueryPort.java b/src/main/java/starlight/application/aireport/required/AiReportQueryPort.java index 8507071..2952036 100644 --- a/src/main/java/starlight/application/aireport/required/AiReportQueryPort.java +++ b/src/main/java/starlight/application/aireport/required/AiReportQueryPort.java @@ -5,6 +5,7 @@ import java.util.Optional; public interface AiReportQueryPort { + Optional findByBusinessPlanId(Long businessPlanId); } diff --git a/src/main/java/starlight/application/aireport/required/ReportGraderPort.java b/src/main/java/starlight/application/aireport/required/ReportGraderPort.java index 4f097b0..f7ffcdf 100644 --- a/src/main/java/starlight/application/aireport/required/ReportGraderPort.java +++ b/src/main/java/starlight/application/aireport/required/ReportGraderPort.java @@ -6,7 +6,9 @@ import java.util.Map; public interface ReportGraderPort { + AiReportResult gradeWithSectionAgents(Map sectionContents, String fullContent); + AiReportResult gradeWithFullPrompt(String content); } diff --git a/src/main/java/starlight/adapter/aireport/report/util/AiReportResponseParser.java b/src/main/java/starlight/application/aireport/util/AiReportResponseParser.java similarity index 99% rename from src/main/java/starlight/adapter/aireport/report/util/AiReportResponseParser.java rename to src/main/java/starlight/application/aireport/util/AiReportResponseParser.java index 0933d3c..c2b5808 100644 --- a/src/main/java/starlight/adapter/aireport/report/util/AiReportResponseParser.java +++ b/src/main/java/starlight/application/aireport/util/AiReportResponseParser.java @@ -1,4 +1,4 @@ -package starlight.adapter.aireport.report.util; +package starlight.application.aireport.util; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; @@ -15,9 +15,6 @@ import java.util.ArrayList; import java.util.List; -/** - * LLM 응답을 파싱하여 AiReportResponse로 변환하는 컴포넌트 - */ @Slf4j @Component @RequiredArgsConstructor From 9e9cc24bb1372d8873266d767981fee6abaacf8c Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Fri, 9 Jan 2026 08:37:49 -0600 Subject: [PATCH 20/74] =?UTF-8?q?[SRLT-124]=20Refactor:=20AiReportResponse?= =?UTF-8?q?Parser=20Application=EC=9D=98=20=EC=9C=A0=ED=8B=B8=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../starlight/adapter/aireport/persistence/AiReportJpa.java | 2 +- .../report/agent/impl/SpringAiFullReportGradeAgent.java | 2 +- .../report/agent/impl/SpringAiSectionGradeAgent.java | 2 +- .../{ReportSupervisor.java => SpringAiReportSupervisor.java} | 2 +- .../java/starlight/application/aireport/AiReportService.java | 5 +---- 5 files changed, 5 insertions(+), 8 deletions(-) rename src/main/java/starlight/adapter/aireport/report/supervisor/{ReportSupervisor.java => SpringAiReportSupervisor.java} (98%) diff --git a/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java b/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java index 82330a4..1627a73 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.adapter.aireport.report.util.AiReportResponseParser; +import starlight.application.aireport.util.AiReportResponseParser; import starlight.application.aireport.required.AiReportCommandPort; import starlight.application.aireport.required.AiReportQueryPort; import starlight.application.expert.required.AiReportSummaryLookupPort; 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 9ca8ab7..01ca547 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.adapter.aireport.report.util.AiReportResponseParser; +import starlight.application.aireport.util.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 860889f..0a852ad 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.adapter.aireport.report.util.AiReportResponseParser; +import starlight.application.aireport.util.AiReportResponseParser; import starlight.application.aireport.provided.dto.AiReportResult; import starlight.shared.enumerate.SectionType; diff --git a/src/main/java/starlight/adapter/aireport/report/supervisor/ReportSupervisor.java b/src/main/java/starlight/adapter/aireport/report/supervisor/SpringAiReportSupervisor.java similarity index 98% rename from src/main/java/starlight/adapter/aireport/report/supervisor/ReportSupervisor.java rename to src/main/java/starlight/adapter/aireport/report/supervisor/SpringAiReportSupervisor.java index 2d75b9c..5608053 100644 --- a/src/main/java/starlight/adapter/aireport/report/supervisor/ReportSupervisor.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.adapter.aireport.report.util.AiReportResponseParser; +import starlight.application.aireport.util.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 5154118..3744ad9 100644 --- a/src/main/java/starlight/application/aireport/AiReportService.java +++ b/src/main/java/starlight/application/aireport/AiReportService.java @@ -7,14 +7,11 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import starlight.adapter.aireport.report.util.AiReportResponseParser; import starlight.application.aireport.provided.AiReportUseCase; import starlight.application.aireport.provided.dto.AiReportResult; import starlight.application.aireport.required.AiReportCommandPort; import starlight.application.aireport.required.ReportGraderPort; -import starlight.application.aireport.required.AiReportQueryPort; -import starlight.application.businessplan.provided.BusinessPlanUseCase; -import starlight.application.businessplan.provided.dto.BusinessPlanResult; +import starlight.application.aireport.util.AiReportResponseParser; import starlight.application.businessplan.required.BusinessPlanCommandPort; import starlight.application.businessplan.required.BusinessPlanQueryPort; import starlight.application.businessplan.util.BusinessPlanContentExtractor; From 5455b50cd4419c527edd5bd32da28f85addd378c Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Fri, 9 Jan 2026 08:38:43 -0600 Subject: [PATCH 21/74] =?UTF-8?q?[SRLT-124]=20Chore:=20AiReportSupervisor?= =?UTF-8?q?=20->=20SpringAiReportSupervisor=EB=A1=9C=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/aireport/report/SpringAiReportGrader.java | 6 +++--- .../report/supervisor/SpringAiReportSupervisor.java | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java b/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java index 5e94c3b..f2eb09b 100644 --- a/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java +++ b/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java @@ -6,7 +6,7 @@ import starlight.adapter.aireport.report.agent.FullReportGradeAgent; import starlight.adapter.aireport.report.agent.SectionGradeAgent; import starlight.adapter.aireport.report.dto.SectionGradingResult; -import starlight.adapter.aireport.report.supervisor.ReportSupervisor; +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; @@ -28,14 +28,14 @@ public class SpringAiReportGrader implements ReportGraderPort { private final Map sectionGradeAgentMap; private final FullReportGradeAgent fullReportGradeAgent; - private final ReportSupervisor supervisor; + private final SpringAiReportSupervisor supervisor; private final BusinessPlanContentExtractor contentExtractor; private final Executor sectionGradingExecutor; public SpringAiReportGrader( List sectionGradeAgentList, FullReportGradeAgent fullReportGradeAgent, - ReportSupervisor supervisor, + SpringAiReportSupervisor supervisor, BusinessPlanContentExtractor contentExtractor, @Qualifier("sectionGradingExecutor") Executor sectionGradingExecutor) { this.sectionGradeAgentMap = sectionGradeAgentList.stream() 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 5608053..ca0178c 100644 --- a/src/main/java/starlight/adapter/aireport/report/supervisor/SpringAiReportSupervisor.java +++ b/src/main/java/starlight/adapter/aireport/report/supervisor/SpringAiReportSupervisor.java @@ -21,7 +21,7 @@ @Slf4j @Component @RequiredArgsConstructor -public class ReportSupervisor { +public class SpringAiReportSupervisor { private final ChatClient.Builder chatClientBuilder; private final AiReportResponseParser responseParser; From e6e25c93a5b389f4a7f1927068e5f73925b756fa Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Fri, 9 Jan 2026 08:39:05 -0600 Subject: [PATCH 22/74] =?UTF-8?q?[SRLT-124]=20Refactor:=20AiReportResponse?= =?UTF-8?q?Parser=20Application=EC=9D=98=20=EC=9C=A0=ED=8B=B8=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/aireport/report/config/SectionAdvisorConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/starlight/adapter/aireport/report/config/SectionAdvisorConfig.java b/src/main/java/starlight/adapter/aireport/report/config/SectionAdvisorConfig.java index c39656d..f1bf069 100644 --- a/src/main/java/starlight/adapter/aireport/report/config/SectionAdvisorConfig.java +++ b/src/main/java/starlight/adapter/aireport/report/config/SectionAdvisorConfig.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.adapter.aireport.report.util.AiReportResponseParser; +import starlight.application.aireport.util.AiReportResponseParser; import starlight.shared.enumerate.SectionType; import java.util.Arrays; From ce465122b8b119c326d7abfc11c83be59769eddb Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Fri, 9 Jan 2026 08:42:33 -0600 Subject: [PATCH 23/74] =?UTF-8?q?[SRLT-124]=20Refactor:=20AI=EB=A6=AC?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=20=EC=84=9C=EB=B9=84=EC=8A=A4=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=82=AC=EC=97=85=EA=B3=84=ED=9A=8D=EC=84=9C=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=EB=A5=BC=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=82=AC?= =?UTF-8?q?=EC=97=85=EA=B3=84=ED=9A=8D=EC=84=9C=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8(BusinessPlanCreationPort)=EB=A5=BC=20?= =?UTF-8?q?=ED=86=B5=ED=95=B4=EC=84=9C=20=EC=82=AC=EC=97=85=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=EC=84=9C=20=EC=83=9D=EC=84=B1=20=EC=96=B4=EB=8C=91?= =?UTF-8?q?=ED=84=B0(BusinessPlanCreationAdapter)=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../creation/BusinessPlanCreationAdapter.java | 29 +++++++++++++++++++ .../application/aireport/AiReportService.java | 15 ++++------ .../required/BusinessPlanCreationPort.java | 7 +++++ 3 files changed, 42 insertions(+), 9 deletions(-) create mode 100644 src/main/java/starlight/adapter/businessplan/creation/BusinessPlanCreationAdapter.java create mode 100644 src/main/java/starlight/application/aireport/required/BusinessPlanCreationPort.java diff --git a/src/main/java/starlight/adapter/businessplan/creation/BusinessPlanCreationAdapter.java b/src/main/java/starlight/adapter/businessplan/creation/BusinessPlanCreationAdapter.java new file mode 100644 index 0000000..c87df3b --- /dev/null +++ b/src/main/java/starlight/adapter/businessplan/creation/BusinessPlanCreationAdapter.java @@ -0,0 +1,29 @@ +package starlight.adapter.businessplan.creation; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import starlight.application.businessplan.provided.BusinessPlanUseCase; +import starlight.application.businessplan.provided.dto.BusinessPlanResult; +import starlight.application.aireport.required.BusinessPlanCreationPort; + +/** + * BusinessPlanCreationPort의 구현체 + * BusinessPlanUseCase를 래핑하여 필요한 기능만 노출합니다. + */ +@Component +@RequiredArgsConstructor +public class BusinessPlanCreationAdapter implements BusinessPlanCreationPort { + + private final BusinessPlanUseCase businessPlanUseCase; + + @Override + public Long createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId) { + BusinessPlanResult.Result result = businessPlanUseCase.createBusinessPlanWithPdf( + title, + pdfUrl, + memberId + ); + return result.businessPlanId(); + } +} + diff --git a/src/main/java/starlight/application/aireport/AiReportService.java b/src/main/java/starlight/application/aireport/AiReportService.java index 3744ad9..b700e7e 100644 --- a/src/main/java/starlight/application/aireport/AiReportService.java +++ b/src/main/java/starlight/application/aireport/AiReportService.java @@ -10,9 +10,11 @@ import starlight.application.aireport.provided.AiReportUseCase; import starlight.application.aireport.provided.dto.AiReportResult; import starlight.application.aireport.required.AiReportCommandPort; +import starlight.application.aireport.required.AiReportQueryPort; import starlight.application.aireport.required.ReportGraderPort; import starlight.application.aireport.util.AiReportResponseParser; import starlight.application.businessplan.required.BusinessPlanCommandPort; +import starlight.application.aireport.required.BusinessPlanCreationPort; import starlight.application.businessplan.required.BusinessPlanQueryPort; import starlight.application.businessplan.util.BusinessPlanContentExtractor; import starlight.application.aireport.required.OcrProviderPort; @@ -34,7 +36,7 @@ public class AiReportService implements AiReportUseCase { private final BusinessPlanCommandPort businessPlanCommandPort; private final BusinessPlanQueryPort businessPlanQueryPort; - private final BusinessPlanUseCase businessPlanUseCase; + private final BusinessPlanCreationPort businessPlanCreationPort; private final AiReportQueryPort aiReportQueryPort; private final AiReportCommandPort aiReportCommandPort; private final ReportGraderPort reportGrader; @@ -83,12 +85,7 @@ public AiReportResult gradeBusinessPlan(Long planId, Long memberId) { public AiReportResult createAndGradePdfBusinessPlan(String title, String pdfUrl, Long memberId) { log.info("PDF 사업계획서 생성 및 AI 채점 시작. title: {}, pdfUrl: {}, memberId: {}", title, pdfUrl, memberId); - BusinessPlanResult.Result businessPlanResult = businessPlanUseCase.createBusinessPlanWithPdf( - title, - pdfUrl, - memberId - ); - Long businessPlanId = businessPlanResult.businessPlanId(); + Long businessPlanId = businessPlanCreationPort.createBusinessPlanWithPdf(title, pdfUrl, memberId); BusinessPlan plan = businessPlanQueryPort.findByIdOrThrow(businessPlanId); log.debug("OCR 시작. pdfUrl: {}", pdfUrl); @@ -178,11 +175,11 @@ private boolean isInvalidGradingResult(AiReportResult result) { (result.feasibilityScore() == null || result.feasibilityScore() == 0) && (result.growthStrategyScore() == null || result.growthStrategyScore() == 0) && (result.teamCompetenceScore() == null || result.teamCompetenceScore() == 0); - + boolean allArraysEmpty = (result.strengths() == null || result.strengths().isEmpty()) && (result.weaknesses() == null || result.weaknesses().isEmpty()) && (result.sectionScores() == null || result.sectionScores().isEmpty()); - + return allScoresZero && allArraysEmpty; } } diff --git a/src/main/java/starlight/application/aireport/required/BusinessPlanCreationPort.java b/src/main/java/starlight/application/aireport/required/BusinessPlanCreationPort.java new file mode 100644 index 0000000..58179bf --- /dev/null +++ b/src/main/java/starlight/application/aireport/required/BusinessPlanCreationPort.java @@ -0,0 +1,7 @@ +package starlight.application.aireport.required; + +public interface BusinessPlanCreationPort { + + Long createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId); +} + From a23aef8a104045612abce2a3781a2d2a38b9adb1 Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Fri, 9 Jan 2026 08:43:10 -0600 Subject: [PATCH 24/74] =?UTF-8?q?[SRLT-124]=20Chore:=20=EC=B6=94=ED=9B=84?= =?UTF-8?q?=20=EA=B3=A0=EB=8F=84=ED=99=94=ED=95=A0=20=EB=A9=94=EC=86=8C?= =?UTF-8?q?=EB=93=9C=20=EC=A3=BC=EC=84=9D=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../util/BusinessPlanContentExtractor.java | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java b/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java index 0e0732b..5555c48 100644 --- a/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java +++ b/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java @@ -124,21 +124,27 @@ public Map extractSectionContents(BusinessPlan businessPlan /** * 전체 텍스트에서 섹션별로 내용을 추출 (PDF 케이스용) + * + * 현재 PDF 입력은 섹션별 채점 대신 FullReportGradeAgent를 사용 중 + * 현재 PDF 처리는 {@link starlight.application.aireport.required.ReportGraderPort#gradeWithFullPrompt(String)}를 사용 + * + * @param fullContent 전체 텍스트 내용 + * @return 섹션별 내용 맵 (현재는 모든 섹션에 전체 내용을 할당) * TODO: 실제 구현 필요 - 섹션 제목을 기준으로 파싱 */ - public Map extractSectionContentsFromText(String fullContent) { - // 간단한 구현: 전체 내용을 각 섹션에 동일하게 할당 - // 나중에 실제 파싱 로직으로 개선 필요 - Map sectionContents = new HashMap<>(); - - // 섹션 제목을 찾아서 분리하는 로직 필요 - // 현재는 전체 내용을 각 섹션에 할당 - sectionContents.put(SectionType.PROBLEM_RECOGNITION, fullContent); - sectionContents.put(SectionType.FEASIBILITY, fullContent); - sectionContents.put(SectionType.GROWTH_STRATEGY, fullContent); - sectionContents.put(SectionType.TEAM_COMPETENCE, fullContent); - - return sectionContents; - } +// public Map extractSectionContentsFromText(String fullContent) { +// // 간단한 구현: 전체 내용을 각 섹션에 동일하게 할당 +// // 나중에 실제 파싱 로직으로 개선 필요 +// Map sectionContents = new HashMap<>(); +// +// // 섹션 제목을 찾아서 분리하는 로직 필요 +// // 현재는 전체 내용을 각 섹션에 할당 +// sectionContents.put(SectionType.PROBLEM_RECOGNITION, fullContent); +// sectionContents.put(SectionType.FEASIBILITY, fullContent); +// sectionContents.put(SectionType.GROWTH_STRATEGY, fullContent); +// sectionContents.put(SectionType.TEAM_COMPETENCE, fullContent); +// +// return sectionContents; +// } } From 581942b7833179c18d08f5bf4f51f785f5d088e5 Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Fri, 9 Jan 2026 08:44:41 -0600 Subject: [PATCH 25/74] =?UTF-8?q?[SRLT-124]=20Refactor:=20=EC=9B=90?= =?UTF-8?q?=EC=9E=90=EC=84=B1=20=EC=9C=A0=EC=A7=80=EB=90=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20SectionGradingCircuitBreaker=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SectionGradingCircuitBreaker.java | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/src/main/java/starlight/adapter/aireport/report/circuitbreaker/SectionGradingCircuitBreaker.java b/src/main/java/starlight/adapter/aireport/report/circuitbreaker/SectionGradingCircuitBreaker.java index 1058a1b..a970d3a 100644 --- a/src/main/java/starlight/adapter/aireport/report/circuitbreaker/SectionGradingCircuitBreaker.java +++ b/src/main/java/starlight/adapter/aireport/report/circuitbreaker/SectionGradingCircuitBreaker.java @@ -47,6 +47,8 @@ public boolean allowRequest() { .getSeconds() >= HALF_OPEN_TIMEOUT_SECONDS) { if (state.compareAndSet(State.OPEN, State.HALF_OPEN)) { successCount.set(0); + failureCount.set(0); // HALF_OPEN 전환 시 failureCount 리셋 + lastFailureTime.set(LocalDateTime.now()); // lastFailureTime도 갱신 log.info("Circuit breaker transitioning to HALF_OPEN"); return true; } @@ -75,15 +77,21 @@ public void recordSuccess() { public void recordFailure() { State current = state.get(); - if (current == State.CLOSED || current == State.HALF_OPEN) { + if (current == State.CLOSED) { int failures = failureCount.incrementAndGet(); lastFailureTime.set(LocalDateTime.now()); if (failures >= FAILURE_THRESHOLD) { - if (state.compareAndSet(current, State.OPEN)) { + if (state.compareAndSet(State.CLOSED, State.OPEN)) { log.warn("Circuit breaker OPENED after {} failures", failures); } } + } else if (current == State.HALF_OPEN) { + // HALF_OPEN에서는 첫 실패 시 즉시 OPEN으로 전환 + lastFailureTime.set(LocalDateTime.now()); + if (state.compareAndSet(State.HALF_OPEN, State.OPEN)) { + log.warn("Circuit breaker OPENED after failure in HALF_OPEN state"); + } } } } @@ -95,21 +103,22 @@ public boolean allowRequest(SectionType sectionType) { ); return circuit.allowRequest(); } - + public void recordSuccess(SectionType sectionType) { - CircuitState circuit = circuitStates.get(sectionType); - if (circuit != null) { - circuit.recordSuccess(); - } + CircuitState circuit = circuitStates.computeIfAbsent( + sectionType, + k -> new CircuitState()); + circuit.recordSuccess(); } - + + public void recordFailure(SectionType sectionType, String errorMessage) { + CircuitState circuit = circuitStates.computeIfAbsent( + sectionType, + k -> new CircuitState()); + circuit.recordFailure(); + } + public void recordFailure(SectionType sectionType) { - CircuitState circuit = circuitStates.get(sectionType); - if (circuit != null) { - circuit.recordFailure(); - } + recordFailure(sectionType, null); } } - - - From 845d8f80575821807446892bd450aa7044c35d61 Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Fri, 9 Jan 2026 08:45:09 -0600 Subject: [PATCH 26/74] =?UTF-8?q?[SRLT-124]=20Test:=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EB=90=9C=20=EB=A1=9C=EC=A7=81=EC=97=90=20=EB=94=B0=EB=9D=BC=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9D=B4=EB=A6=84=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/ai/OpenAiReportGraderTest.java | 101 --------- .../adapter/ai/infra/OpenAiGeneratorTest.java | 132 ----------- .../report/SpringAiReportGraderTest.java | 170 ++++++++++++++ .../SpringAiChecklistGraderTest.java} | 12 +- ...va => AiReportServiceIntegrationTest.java} | 211 +++++++++--------- ...Test.java => AiReportServiceUnitTest.java} | 55 +++-- .../util/AiReportResponseParserTest.java | 4 +- ...> BusinessPlanServiceIntegrationTest.java} | 4 +- ....java => BusinessPlanServiceUnitTest.java} | 44 ++-- ... => CredentialServiceIntegrationTest.java} | 4 +- ...st.java => CredentialServiceUnitTest.java} | 2 +- ...java => MemberServiceIntegrationTest.java} | 2 +- ...itTest.java => MemberServiceUnitTest.java} | 2 +- 13 files changed, 355 insertions(+), 388 deletions(-) delete mode 100644 src/test/java/starlight/adapter/ai/OpenAiReportGraderTest.java delete mode 100644 src/test/java/starlight/adapter/ai/infra/OpenAiGeneratorTest.java create mode 100644 src/test/java/starlight/adapter/aireport/report/SpringAiReportGraderTest.java rename src/test/java/starlight/adapter/{ai/AiChecklistGraderTest.java => businessplan/checklist/SpringAiChecklistGraderTest.java} (87%) rename src/test/java/starlight/application/aireport/{AiReportServiceImplIntegrationTest.java => AiReportServiceIntegrationTest.java} (67%) rename src/test/java/starlight/application/aireport/{AiReportServiceImplUnitTest.java => AiReportServiceUnitTest.java} (71%) rename src/test/java/starlight/{adapter/ai => application/aireport}/util/AiReportResponseParserTest.java (98%) rename src/test/java/starlight/application/businessplan/{BusinessPlanServiceImplIntegrationTest.java => BusinessPlanServiceIntegrationTest.java} (97%) rename src/test/java/starlight/application/businessplan/{BusinessPlanServiceImplUnitTest.java => BusinessPlanServiceUnitTest.java} (94%) rename src/test/java/starlight/application/member/{CredentialServiceImplIntegrationTest.java => CredentialServiceIntegrationTest.java} (95%) rename src/test/java/starlight/application/member/{CredentialServiceImplUnitTest.java => CredentialServiceUnitTest.java} (98%) rename src/test/java/starlight/application/member/{MemberQueryServiceIntegrationTest.java => MemberServiceIntegrationTest.java} (98%) rename src/test/java/starlight/application/member/{MemberQueryServiceUnitTest.java => MemberServiceUnitTest.java} (98%) diff --git a/src/test/java/starlight/adapter/ai/OpenAiReportGraderTest.java b/src/test/java/starlight/adapter/ai/OpenAiReportGraderTest.java deleted file mode 100644 index 359b48c..0000000 --- a/src/test/java/starlight/adapter/ai/OpenAiReportGraderTest.java +++ /dev/null @@ -1,101 +0,0 @@ -package starlight.adapter.ai; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import starlight.adapter.aireport.report.SpringAiReportGrader; -import starlight.adapter.aireport.report.agent.impl.SpringAiFullReportGradeAgent; -import starlight.adapter.aireport.report.util.AiReportResponseParser; -import starlight.application.aireport.provided.dto.AiReportResult; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@DisplayName("OpenAiReportGrader 테스트") -class OpenAiReportGraderTest { - - @Test - @DisplayName("컨텐츠를 채점하여 AiReportResponse를 반환한다") - void gradeContent_returnsAiReportResponse() { - // given - String content = "사업계획서 내용"; - String llmResponse = """ - { - "problemRecognitionScore": 20, - "feasibilityScore": 25, - "growthStrategyScore": 30, - "teamCompetenceScore": 20, - "sectionScores": [ - { - "sectionType": "PROBLEM_RECOGNITION", - "gradingListScores": "[{\\"item\\":\\"항목1\\",\\"score\\":5,\\"maxScore\\":5}]" - } - ], - "strengths": [ - {"title": "강점1", "content": "내용1"} - ], - "weaknesses": [ - {"title": "약점1", "content": "내용1"} - ] - } - """; - - SpringAiFullReportGradeAgent generator = mock(SpringAiFullReportGradeAgent.class); - when(generator.generateReport(content)).thenReturn(llmResponse); - - AiReportResponseParser parser = mock(AiReportResponseParser.class); - AiReportResult expectedResponse = AiReportResult.fromGradingResult( - 20, 25, 30, 20, - List.of(new AiReportResult.SectionScoreDetailResponse("PROBLEM_RECOGNITION", "[{\"item\":\"항목1\",\"score\":5,\"maxScore\":5}]")), - List.of(new AiReportResult.StrengthWeakness("강점1", "내용1")), - List.of(new AiReportResult.StrengthWeakness("약점1", "내용1")) - ); - when(parser.parse(llmResponse)).thenReturn(expectedResponse); - - SpringAiReportGrader sut = new SpringAiReportGrader(generator, parser); - - // when - AiReportResult result = sut.gradeWithSectionAgents(content); - - // then - assertThat(result).isNotNull(); - assertThat(result.problemRecognitionScore()).isEqualTo(20); - assertThat(result.feasibilityScore()).isEqualTo(25); - assertThat(result.growthStrategyScore()).isEqualTo(30); - assertThat(result.teamCompetenceScore()).isEqualTo(20); - assertThat(result.totalScore()).isEqualTo(95); - assertThat(result.strengths()).hasSize(1); - assertThat(result.weaknesses()).hasSize(1); - assertThat(result.sectionScores()).hasSize(1); - - verify(generator).generateReport(content); - verify(parser).parse(llmResponse); - } - - @Test - @DisplayName("각 컴포넌트가 순서대로 호출된다") - void gradeContent_callsComponentsInOrder() { - // given - String content = "사업계획서 내용"; - String llmResponse = "{}"; - - SpringAiFullReportGradeAgent generator = mock(SpringAiFullReportGradeAgent.class); - when(generator.generateReport(any())).thenReturn(llmResponse); - - AiReportResponseParser parser = mock(AiReportResponseParser.class); - when(parser.parse(any())).thenReturn(AiReportResult.fromGradingResult(0, 0, 0, 0, List.of(), List.of(), List.of())); - - SpringAiReportGrader sut = new SpringAiReportGrader(generator, parser); - - // when - sut.gradeWithSectionAgents(content); - - // then - var inOrder = inOrder(generator, parser); - inOrder.verify(generator).generateReport(content); - inOrder.verify(parser).parse(llmResponse); - } -} - diff --git a/src/test/java/starlight/adapter/ai/infra/OpenAiGeneratorTest.java b/src/test/java/starlight/adapter/ai/infra/OpenAiGeneratorTest.java deleted file mode 100644 index 767c433..0000000 --- a/src/test/java/starlight/adapter/ai/infra/OpenAiGeneratorTest.java +++ /dev/null @@ -1,132 +0,0 @@ -package starlight.adapter.ai.infra; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.ai.chat.client.ChatClient; -import org.springframework.ai.chat.prompt.Prompt; -import starlight.adapter.aireport.report.provider.SpringAiAdvisorProvider; -import starlight.adapter.aireport.report.agent.impl.SpringAiFullReportGradeAgent; -import starlight.adapter.aireport.report.provider.ReportPromptProvider; -import starlight.domain.businessplan.enumerate.SubSectionType; - -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; -import static org.mockito.Mockito.RETURNS_DEEP_STUBS; - -class OpenAiGeneratorTest { - - @Test - @DisplayName("올바른 JSON 배열을 파싱해 반환") - void generateChecklistArray_parsesJson() { - ChatClient chatClient = mock(ChatClient.class, RETURNS_DEEP_STUBS); - ChatClient.Builder builder = mock(ChatClient.Builder.class); - when(builder.build()).thenReturn(chatClient); - - // RETURNS_DEEP_STUBS를 사용하면 체인 전체가 자동으로 mock됨 - // 마지막 content()만 반환값 설정 - when(chatClient.prompt(any(Prompt.class)) - .options(any()) - .advisors(any(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)) - .call() - .content()).thenReturn("[true,false,true,false,true]"); - - ReportPromptProvider reportPromptProvider = mock(ReportPromptProvider.class); - when(reportPromptProvider.createChecklistGradingPrompt(any(SubSectionType.class), anyString(), anyList(), anyList())) - .thenReturn(mock(Prompt.class)); - - SpringAiAdvisorProvider advisorProvider = mock(SpringAiAdvisorProvider.class); - when(advisorProvider.getSimpleLoggerAdvisor()) - .thenReturn(mock(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)); - - SpringAiFullReportGradeAgent sut = new SpringAiFullReportGradeAgent(builder, reportPromptProvider, advisorProvider); - - List result = sut.generateChecklistArray( - SubSectionType.OVERVIEW_BASIC, - "test content", - List.of("c1", "c2", "c3", "c4", "c5"), - List.of("d1", "d2", "d3", "d4", "d5") - ); - assertThat(result).containsExactly(true, false, true, false, true); - } - - @Test - @DisplayName("파싱 실패 시 보수적으로 모두 false 반환") - void generateChecklistArray_parseFail_returnsAllFalse() { - ChatClient chatClient = mock(ChatClient.class, RETURNS_DEEP_STUBS); - ChatClient.Builder builder = mock(ChatClient.Builder.class); - when(builder.build()).thenReturn(chatClient); - - // RETURNS_DEEP_STUBS를 사용하면 체인 전체가 자동으로 mock됨 - // 마지막 content()만 반환값 설정 - when(chatClient.prompt(any(Prompt.class)) - .options(any()) - .advisors(any(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)) - .call() - .content()).thenReturn("not-json"); - - ReportPromptProvider reportPromptProvider = mock(ReportPromptProvider.class); - when(reportPromptProvider.createChecklistGradingPrompt(any(SubSectionType.class), anyString(), anyList(), anyList())) - .thenReturn(mock(Prompt.class)); - - SpringAiAdvisorProvider advisorProvider = mock(SpringAiAdvisorProvider.class); - when(advisorProvider.getSimpleLoggerAdvisor()) - .thenReturn(mock(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)); - - SpringAiFullReportGradeAgent sut = new SpringAiFullReportGradeAgent(builder, reportPromptProvider, advisorProvider); - - List result = sut.generateChecklistArray( - SubSectionType.OVERVIEW_BASIC, - "test content", - List.of("c1", "c2", "c3", "c4", "c5"), - List.of("d1", "d2", "d3", "d4", "d5") - ); - assertThat(result).containsExactly(false, false, false, false, false); - } - - @Test - @DisplayName("generateReport는 OpenAI 응답 문자열을 반환한다") - void generateReport_returnsString() { - ChatClient chatClient = mock(ChatClient.class, RETURNS_DEEP_STUBS); - ChatClient.Builder builder = mock(ChatClient.Builder.class); - when(builder.build()).thenReturn(chatClient); - - String expectedResponse = """ - { - "problemRecognitionScore": 20, - "feasibilityScore": 25, - "growthStrategyScore": 30, - "teamCompetenceScore": 20, - "sectionScores": [], - "strengths": [], - "weaknesses": [] - } - """.trim(); - - // RETURNS_DEEP_STUBS를 사용하면 체인 전체가 자동으로 mock됨 - // 마지막 content()만 반환값 설정 - when(chatClient.prompt(any(Prompt.class)) - .options(any()) - .advisors(any(), any()) - .call() - .content()).thenReturn(expectedResponse); - - ReportPromptProvider reportPromptProvider = mock(ReportPromptProvider.class); - when(reportPromptProvider.createReportGradingPrompt(anyString())) - .thenReturn(mock(Prompt.class)); - - SpringAiAdvisorProvider advisorProvider = mock(SpringAiAdvisorProvider.class); - when(advisorProvider.getQuestionAnswerAdvisor(anyDouble(), anyInt(), any())) - .thenReturn(mock(org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor.class)); - when(advisorProvider.getSimpleLoggerAdvisor()) - .thenReturn(mock(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)); - - SpringAiFullReportGradeAgent sut = new SpringAiFullReportGradeAgent(builder, reportPromptProvider, advisorProvider); - - String result = sut.generateReport("test content"); - - assertThat(result).isEqualTo(expectedResponse); - } -} diff --git a/src/test/java/starlight/adapter/aireport/report/SpringAiReportGraderTest.java b/src/test/java/starlight/adapter/aireport/report/SpringAiReportGraderTest.java new file mode 100644 index 0000000..79f770e --- /dev/null +++ b/src/test/java/starlight/adapter/aireport/report/SpringAiReportGraderTest.java @@ -0,0 +1,170 @@ +package starlight.adapter.aireport.report; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import starlight.adapter.aireport.report.agent.FullReportGradeAgent; +import starlight.adapter.aireport.report.agent.SectionGradeAgent; +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; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@DisplayName("SpringAiReportGrader 테스트") +class SpringAiReportGraderTest { + + @Test + @DisplayName("전체 프롬프트로 채점하여 AiReportResult를 반환한다") + void gradeWithFullPrompt_returnsAiReportResult() { + // given + String content = "사업계획서 내용"; + + FullReportGradeAgent fullReportGradeAgent = mock(FullReportGradeAgent.class); + AiReportResult expectedResponse = AiReportResult.fromGradingResult( + 20, 25, 30, 20, + List.of(new AiReportResult.SectionScoreDetailResponse("PROBLEM_RECOGNITION", "[{\"item\":\"항목1\",\"score\":5,\"maxScore\":5}]")), + List.of(new AiReportResult.StrengthWeakness("강점1", "내용1")), + List.of(new AiReportResult.StrengthWeakness("약점1", "내용1")) + ); + when(fullReportGradeAgent.gradeFullReport(content)).thenReturn(expectedResponse); + + SpringAiReportGrader sut = new SpringAiReportGrader( + List.of(), + fullReportGradeAgent, + mock(SpringAiReportSupervisor.class), + mock(BusinessPlanContentExtractor.class), + mock(Executor.class) + ); + + // when + AiReportResult result = sut.gradeWithFullPrompt(content); + + // then + assertThat(result).isNotNull(); + assertThat(result.problemRecognitionScore()).isEqualTo(20); + assertThat(result.feasibilityScore()).isEqualTo(25); + assertThat(result.growthStrategyScore()).isEqualTo(30); + assertThat(result.teamCompetenceScore()).isEqualTo(20); + assertThat(result.totalScore()).isEqualTo(95); + assertThat(result.strengths()).hasSize(1); + assertThat(result.weaknesses()).hasSize(1); + assertThat(result.sectionScores()).hasSize(1); + + verify(fullReportGradeAgent).gradeFullReport(content); + } + + @Test + @DisplayName("섹션별 에이전트로 채점하여 AiReportResult를 반환한다") + void gradeWithSectionAgents_returnsAiReportResult() { + // given + Map sectionContents = new HashMap<>(); + sectionContents.put(SectionType.PROBLEM_RECOGNITION, "문제인식 내용"); + sectionContents.put(SectionType.FEASIBILITY, "실현가능성 내용"); + sectionContents.put(SectionType.GROWTH_STRATEGY, "성장전략 내용"); + sectionContents.put(SectionType.TEAM_COMPETENCE, "팀역량 내용"); + String fullContent = "전체 사업계획서 내용"; + + // 각 섹션에 맞는 Agent 모킹 + SectionGradeAgent problemRecognitionAgent = mock(SectionGradeAgent.class); + when(problemRecognitionAgent.getSectionType()).thenReturn(SectionType.PROBLEM_RECOGNITION); + when(problemRecognitionAgent.gradeSection(anyString())).thenReturn( + SectionGradingResult.success( + SectionType.PROBLEM_RECOGNITION, + 20, + new AiReportResult.SectionScoreDetailResponse("PROBLEM_RECOGNITION", "[{\"item\":\"근본 원인 논리 분석\",\"score\":5,\"maxScore\":5}]") + ) + ); + + SectionGradeAgent feasibilityAgent = mock(SectionGradeAgent.class); + when(feasibilityAgent.getSectionType()).thenReturn(SectionType.FEASIBILITY); + when(feasibilityAgent.gradeSection(anyString())).thenReturn( + SectionGradingResult.success( + SectionType.FEASIBILITY, + 25, + new AiReportResult.SectionScoreDetailResponse("FEASIBILITY", "[{\"item\":\"로드맵 구체성\",\"score\":6,\"maxScore\":6}]") + ) + ); + + SectionGradeAgent growthStrategyAgent = mock(SectionGradeAgent.class); + when(growthStrategyAgent.getSectionType()).thenReturn(SectionType.GROWTH_STRATEGY); + when(growthStrategyAgent.gradeSection(anyString())).thenReturn( + SectionGradingResult.success( + SectionType.GROWTH_STRATEGY, + 30, + new AiReportResult.SectionScoreDetailResponse("GROWTH_STRATEGY", "[{\"item\":\"BM 9요소 완결·연계성\",\"score\":6,\"maxScore\":6}]") + ) + ); + + SectionGradeAgent teamCompetenceAgent = mock(SectionGradeAgent.class); + when(teamCompetenceAgent.getSectionType()).thenReturn(SectionType.TEAM_COMPETENCE); + when(teamCompetenceAgent.gradeSection(anyString())).thenReturn( + SectionGradingResult.success( + SectionType.TEAM_COMPETENCE, + 20, + new AiReportResult.SectionScoreDetailResponse("TEAM_COMPETENCE", "[{\"item\":\"창업자 전문성·연관성\",\"score\":5,\"maxScore\":5}]") + ) + ); + + List sectionAgents = List.of( + problemRecognitionAgent, + feasibilityAgent, + growthStrategyAgent, + teamCompetenceAgent + ); + + FullReportGradeAgent fullReportGradeAgent = mock(FullReportGradeAgent.class); + SpringAiReportSupervisor supervisor = mock(SpringAiReportSupervisor.class); + BusinessPlanContentExtractor contentExtractor = mock(BusinessPlanContentExtractor.class); + // 실제 Executor 사용 (비동기 실행을 위해) + Executor executor = Executors.newFixedThreadPool(4); + + SpringAiReportGrader sut = new SpringAiReportGrader( + sectionAgents, + fullReportGradeAgent, + supervisor, + contentExtractor, + executor + ); + + when(supervisor.generateStrengths(anyString(), anyList())).thenReturn( + List.of(new AiReportResult.StrengthWeakness("강점1", "내용1")) + ); + when(supervisor.generateWeaknesses(anyString(), anyList())).thenReturn( + List.of(new AiReportResult.StrengthWeakness("약점1", "내용1")) + ); + + // when + AiReportResult result = sut.gradeWithSectionAgents(sectionContents, fullContent); + + // then + assertThat(result).isNotNull(); + assertThat(result.problemRecognitionScore()).isEqualTo(20); + assertThat(result.feasibilityScore()).isEqualTo(25); + assertThat(result.growthStrategyScore()).isEqualTo(30); + assertThat(result.teamCompetenceScore()).isEqualTo(20); + assertThat(result.totalScore()).isEqualTo(95); + assertThat(result.strengths()).hasSize(1); + assertThat(result.weaknesses()).hasSize(1); + + // 각 Agent가 호출되었는지 확인 + verify(problemRecognitionAgent).gradeSection("문제인식 내용"); + verify(feasibilityAgent).gradeSection("실현가능성 내용"); + verify(growthStrategyAgent).gradeSection("성장전략 내용"); + verify(teamCompetenceAgent).gradeSection("팀역량 내용"); + verify(supervisor).generateStrengths(eq(fullContent), anyList()); + verify(supervisor).generateWeaknesses(eq(fullContent), anyList()); + } +} + diff --git a/src/test/java/starlight/adapter/ai/AiChecklistGraderTest.java b/src/test/java/starlight/adapter/businessplan/checklist/SpringAiChecklistGraderTest.java similarity index 87% rename from src/test/java/starlight/adapter/ai/AiChecklistGraderTest.java rename to src/test/java/starlight/adapter/businessplan/checklist/SpringAiChecklistGraderTest.java index c54eebc..3ca07ca 100644 --- a/src/test/java/starlight/adapter/ai/AiChecklistGraderTest.java +++ b/src/test/java/starlight/adapter/businessplan/checklist/SpringAiChecklistGraderTest.java @@ -1,9 +1,8 @@ -package starlight.adapter.ai; +package starlight.adapter.businessplan.checklist; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import starlight.adapter.businessplan.checklist.SpringAiChecklistGrader; -import starlight.adapter.aireport.report.agent.impl.SpringAiFullReportGradeAgent; +import starlight.adapter.businessplan.checklist.agent.SpringAiChecklistAgent; import starlight.adapter.businessplan.checklist.provider.ChecklistPromptProvider; import starlight.domain.businessplan.enumerate.SubSectionType; @@ -13,12 +12,13 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; -class AiChecklistGraderTest { +@DisplayName("SpringAiChecklistGrader 테스트") +class SpringAiChecklistGraderTest { @Test @DisplayName("criteria별 컨텍스트를 합치고 LLM 결과를 반환") void check_returnsFromLlm() { - SpringAiFullReportGradeAgent generator = mock(SpringAiFullReportGradeAgent.class); + SpringAiChecklistAgent generator = mock(SpringAiChecklistAgent.class); when(generator.generateChecklistArray(any(SubSectionType.class), anyString(), anyList(), anyList())) .thenReturn(List.of(true, false, true, false, true)); @@ -45,7 +45,7 @@ void check_returnsFromLlm() { @Test @DisplayName("LLM 결과 길이가 5보다 짧으면 false로 패딩") void check_normalizesToFive() { - SpringAiFullReportGradeAgent generator = mock(SpringAiFullReportGradeAgent.class); + SpringAiChecklistAgent generator = mock(SpringAiChecklistAgent.class); when(generator.generateChecklistArray(any(SubSectionType.class), anyString(), anyList(), anyList())) .thenReturn(List.of(true)); diff --git a/src/test/java/starlight/application/aireport/AiReportServiceImplIntegrationTest.java b/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java similarity index 67% rename from src/test/java/starlight/application/aireport/AiReportServiceImplIntegrationTest.java rename to src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java index 98df1d1..6dd1917 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceImplIntegrationTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java @@ -10,17 +10,19 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; -import starlight.adapter.aireport.report.util.AiReportResponseParser; +import starlight.application.aireport.util.AiReportResponseParser; import starlight.adapter.aireport.persistence.AiReportJpa; import starlight.adapter.aireport.persistence.AiReportRepository; import starlight.adapter.businessplan.persistence.BusinessPlanJpa; import starlight.adapter.businessplan.persistence.BusinessPlanRepository; import starlight.application.aireport.provided.dto.AiReportResult; -import starlight.application.aireport.required.ReportGraderPort; +import starlight.application.aireport.required.AiReportCommandPort; +import starlight.application.aireport.required.AiReportQueryPort; +import starlight.application.aireport.required.BusinessPlanCreationPort; import starlight.application.aireport.required.OcrProviderPort; -import starlight.application.businessplan.provided.dto.SubSectionResult; -import starlight.application.businessplan.provided.BusinessPlanUseCase; -import starlight.application.businessplan.provided.dto.BusinessPlanResult; +import starlight.application.aireport.required.ReportGraderPort; +import starlight.application.businessplan.required.BusinessPlanCommandPort; +import starlight.application.businessplan.required.BusinessPlanQueryPort; import starlight.application.businessplan.util.BusinessPlanContentExtractor; import starlight.domain.aireport.entity.AiReport; import starlight.domain.businessplan.entity.BusinessPlan; @@ -35,9 +37,9 @@ @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) -@Import({AiReportService.class, AiReportJpa.class, BusinessPlanJpa.class, AiReportServiceImplIntegrationTest.TestBeans.class}) -@DisplayName("AiReportServiceImpl 통합 테스트") -class AiReportServiceImplIntegrationTest { +@Import({AiReportService.class, AiReportJpa.class, BusinessPlanJpa.class, AiReportServiceIntegrationTest.TestBeans.class}) +@DisplayName("AiReportService 통합 테스트") +class AiReportServiceIntegrationTest { @Autowired AiReportService sut; @@ -53,14 +55,52 @@ static class TestBeans { @Bean ReportGraderPort aiReportGrader() { - return content -> { - // 간단한 mock 응답 반환 - return AiReportResult.fromGradingResult( - 20, 25, 30, 20, - List.of(new AiReportResult.SectionScoreDetailResponse("PROBLEM_RECOGNITION", "[{\"item\":\"항목1\",\"score\":5,\"maxScore\":5}]")), - List.of(new AiReportResult.StrengthWeakness("강점1", "내용1")), - List.of(new AiReportResult.StrengthWeakness("약점1", "내용1")) - ); + return new ReportGraderPort() { + @Override + public AiReportResult gradeWithSectionAgents(java.util.Map sectionContents, String fullContent) { + return AiReportResult.fromGradingResult( + 20, 25, 30, 20, + List.of( + new AiReportResult.SectionScoreDetailResponse("PROBLEM_RECOGNITION", "[{\"item\":\"근본 원인 논리 분석\",\"score\":5,\"maxScore\":5}]"), + new AiReportResult.SectionScoreDetailResponse("FEASIBILITY", "[{\"item\":\"로드맵 구체성\",\"score\":6,\"maxScore\":6}]"), + new AiReportResult.SectionScoreDetailResponse("GROWTH_STRATEGY", "[{\"item\":\"BM 9요소 완결·연계성\",\"score\":6,\"maxScore\":6}]"), + new AiReportResult.SectionScoreDetailResponse("TEAM_COMPETENCE", "[{\"item\":\"창업자 전문성·연관성\",\"score\":5,\"maxScore\":5}]") + ), + List.of( + new AiReportResult.StrengthWeakness("강점1", "내용1"), + new AiReportResult.StrengthWeakness("강점2", "내용2"), + new AiReportResult.StrengthWeakness("강점3", "내용3") + ), + List.of( + new AiReportResult.StrengthWeakness("약점1", "내용1"), + new AiReportResult.StrengthWeakness("약점2", "내용2"), + new AiReportResult.StrengthWeakness("약점3", "내용3") + ) + ); + } + + @Override + public AiReportResult gradeWithFullPrompt(String content) { + return AiReportResult.fromGradingResult( + 20, 25, 30, 20, + List.of( + new AiReportResult.SectionScoreDetailResponse("PROBLEM_RECOGNITION", "[{\"item\":\"근본 원인 논리 분석\",\"score\":5,\"maxScore\":5}]"), + new AiReportResult.SectionScoreDetailResponse("FEASIBILITY", "[{\"item\":\"로드맵 구체성\",\"score\":6,\"maxScore\":6}]"), + new AiReportResult.SectionScoreDetailResponse("GROWTH_STRATEGY", "[{\"item\":\"BM 9요소 완결·연계성\",\"score\":6,\"maxScore\":6}]"), + new AiReportResult.SectionScoreDetailResponse("TEAM_COMPETENCE", "[{\"item\":\"창업자 전문성·연관성\",\"score\":5,\"maxScore\":5}]") + ), + List.of( + new AiReportResult.StrengthWeakness("강점1", "내용1"), + new AiReportResult.StrengthWeakness("강점2", "내용2"), + new AiReportResult.StrengthWeakness("강점3", "내용3") + ), + List.of( + new AiReportResult.StrengthWeakness("약점1", "내용1"), + new AiReportResult.StrengthWeakness("약점2", "내용2"), + new AiReportResult.StrengthWeakness("약점3", "내용3") + ) + ); + } }; } @@ -75,69 +115,70 @@ AiReportResponseParser responseParser() { } @Bean - BusinessPlanUseCase businessPlanService(BusinessPlanRepository businessPlanRepository) { - return new BusinessPlanUseCase() { + BusinessPlanCommandPort businessPlanCommandPort(BusinessPlanRepository businessPlanRepository) { + return new BusinessPlanCommandPort() { @Override - public BusinessPlanResult.PreviewPage getBusinessPlanList(Long memberId, org.springframework.data.domain.Pageable pageable) { - throw new UnsupportedOperationException("Not implemented in test"); - } - @Override - public BusinessPlanResult.Result createBusinessPlan(Long memberId) { - BusinessPlan plan = BusinessPlan.create("default title", memberId); - BusinessPlan saved = businessPlanRepository.save(plan); - return BusinessPlanResult.Result.from(saved, "Business plan created"); + public BusinessPlan save(BusinessPlan businessPlan) { + return businessPlanRepository.save(businessPlan); } @Override - public BusinessPlanResult.Result createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId) { - BusinessPlan plan = BusinessPlan.createWithPdf(title, memberId, pdfUrl); - BusinessPlan saved = businessPlanRepository.save(plan); - return BusinessPlanResult.Result.from(saved, "PDF Business plan created"); - } - - @Override - public BusinessPlanResult.Result getBusinessPlanInfo(Long planId, Long memberId) { - throw new UnsupportedOperationException("Not implemented in test"); - } - - @Override - public BusinessPlanResult.Detail getBusinessPlanDetail(Long planId, Long memberId) { - throw new UnsupportedOperationException("Not implemented in test"); + public void delete(BusinessPlan businessPlan) { + businessPlanRepository.delete(businessPlan); } + }; + } + @Bean + BusinessPlanQueryPort businessPlanQueryPort(BusinessPlanRepository businessPlanRepository) { + return new BusinessPlanQueryPort() { @Override - public String updateBusinessPlanTitle(Long planId, String title, Long memberId) { - throw new UnsupportedOperationException("Not implemented in test"); + public BusinessPlan findByIdOrThrow(Long id) { + return businessPlanRepository.findById(id) + .orElseThrow(() -> new RuntimeException("BusinessPlan not found: " + id)); } @Override - public BusinessPlanResult.Result deleteBusinessPlan(Long planId, Long memberId) { - throw new UnsupportedOperationException("Not implemented in test"); + public BusinessPlan findWithAllSubSectionsOrThrow(Long id) { + return businessPlanRepository.findByIdWithAllSubSections(id) + .orElseThrow(() -> new RuntimeException("BusinessPlan not found: " + id)); } @Override - public SubSectionResult.Result upsertSubSection( - Long planId, com.fasterxml.jackson.databind.JsonNode jsonNode, List checks, - starlight.domain.businessplan.enumerate.SubSectionType subSectionType, Long memberId) { - throw new UnsupportedOperationException("Not implemented in test"); + public org.springframework.data.domain.Page findPreviewPage(Long memberId, org.springframework.data.domain.Pageable pageable) { + return businessPlanRepository.findAllByMemberIdOrderedByLastSavedAt(memberId, pageable); } + }; + } + @Bean + BusinessPlanCreationPort businessPlanCreationPort(BusinessPlanRepository businessPlanRepository) { + return new BusinessPlanCreationPort() { @Override - public SubSectionResult.Detail getSubSectionDetail( - Long planId, starlight.domain.businessplan.enumerate.SubSectionType subSectionType, Long memberId) { - throw new UnsupportedOperationException("Not implemented in test"); + public Long createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId) { + BusinessPlan plan = BusinessPlan.createWithPdf(title, memberId, pdfUrl); + BusinessPlan saved = businessPlanRepository.save(plan); + return saved.getId(); } + }; + } + @Bean + AiReportCommandPort aiReportCommandPort(AiReportRepository aiReportRepository) { + return new AiReportCommandPort() { @Override - public List checkAndUpdateSubSection(Long planId, com.fasterxml.jackson.databind.JsonNode jsonNode, - starlight.domain.businessplan.enumerate.SubSectionType subSectionType, Long memberId) { - throw new UnsupportedOperationException("Not implemented in test"); + public starlight.domain.aireport.entity.AiReport save(starlight.domain.aireport.entity.AiReport aiReport) { + return aiReportRepository.save(aiReport); } + }; + } + @Bean + AiReportQueryPort aiReportQueryPort(AiReportRepository aiReportRepository) { + return new AiReportQueryPort() { @Override - public SubSectionResult.Result deleteSubSection( - Long planId, starlight.domain.businessplan.enumerate.SubSectionType subSectionType, Long memberId) { - throw new UnsupportedOperationException("Not implemented in test"); + public Optional findByBusinessPlanId(Long businessPlanId) { + return aiReportRepository.findByBusinessPlanId(businessPlanId); } }; } @@ -162,44 +203,6 @@ BusinessPlanContentExtractor businessPlanContentExtractor() { return new BusinessPlanContentExtractor(); } - @Bean - LlmGenerator llmGenerator() { - return new LlmGenerator() { - @Override - public java.util.List generateChecklistArray( - starlight.domain.businessplan.enumerate.SubSectionType subSectionType, - String content, - java.util.List criteria, - java.util.List detailedCriteria) { - throw new UnsupportedOperationException("Not implemented in test"); - } - - @Override - public String generateReport(String content) { - // PDF 채점용 mock JSON 응답 반환 - return """ - { - "problemRecognitionScore": 20, - "feasibilityScore": 25, - "growthStrategyScore": 30, - "teamCompetenceScore": 20, - "sectionScores": [ - { - "sectionType": "PROBLEM_RECOGNITION", - "gradingListScores": "[{\\"item\\":\\"항목1\\",\\"score\\":5,\\"maxScore\\":5}]" - } - ], - "strengths": [ - {"title": "강점1", "content": "내용1"} - ], - "weaknesses": [ - {"title": "약점1", "content": "내용1"} - ] - } - """; - } - }; - } } /** @@ -264,9 +267,9 @@ void gradeBusinessPlan_createsNewReport() { assertThat(result.feasibilityScore()).isEqualTo(25); assertThat(result.growthStrategyScore()).isEqualTo(30); assertThat(result.teamCompetenceScore()).isEqualTo(20); - assertThat(result.strengths()).hasSize(1); - assertThat(result.weaknesses()).hasSize(1); - assertThat(result.sectionScores()).hasSize(1); + assertThat(result.strengths()).hasSize(3); + assertThat(result.weaknesses()).hasSize(3); + assertThat(result.sectionScores()).hasSize(4); // DB에 저장되었는지 확인 Optional savedReport = aiReportRepository.findByBusinessPlanId(planId); @@ -335,9 +338,9 @@ void getAiReport_returnsResponse() { assertThat(result.id()).isNotNull(); assertThat(result.businessPlanId()).isEqualTo(planId); assertThat(result.totalScore()).isEqualTo(95); - assertThat(result.strengths()).hasSize(1); - assertThat(result.weaknesses()).hasSize(1); - assertThat(result.sectionScores()).hasSize(1); + assertThat(result.strengths()).hasSize(3); + assertThat(result.weaknesses()).hasSize(3); + assertThat(result.sectionScores()).hasSize(4); } @Test @@ -392,9 +395,9 @@ void createAndGradePdfBusinessPlan_createsBusinessPlanAndReport() { assertThat(result.feasibilityScore()).isEqualTo(25); assertThat(result.growthStrategyScore()).isEqualTo(30); assertThat(result.teamCompetenceScore()).isEqualTo(20); - assertThat(result.strengths()).hasSize(1); - assertThat(result.weaknesses()).hasSize(1); - assertThat(result.sectionScores()).hasSize(1); + assertThat(result.strengths()).hasSize(3); + assertThat(result.weaknesses()).hasSize(3); + assertThat(result.sectionScores()).hasSize(4); // BusinessPlan이 생성되었는지 확인 BusinessPlan createdPlan = businessPlanRepository.findById(result.businessPlanId()).orElseThrow(); diff --git a/src/test/java/starlight/application/aireport/AiReportServiceImplUnitTest.java b/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java similarity index 71% rename from src/test/java/starlight/application/aireport/AiReportServiceImplUnitTest.java rename to src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java index 3fb4c9c..724be39 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceImplUnitTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java @@ -3,22 +3,27 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import starlight.adapter.aireport.report.util.AiReportResponseParser; +import starlight.application.aireport.util.AiReportResponseParser; import starlight.application.aireport.provided.dto.AiReportResult; +import starlight.application.aireport.required.BusinessPlanCreationPort; import starlight.application.aireport.required.ReportGraderPort; import starlight.application.aireport.required.AiReportQueryPort; +import starlight.application.aireport.required.AiReportCommandPort; import starlight.application.aireport.required.OcrProviderPort; -import starlight.application.businessplan.provided.BusinessPlanUseCase; import starlight.application.businessplan.required.BusinessPlanQueryPort; +import starlight.application.businessplan.required.BusinessPlanCommandPort; import starlight.application.businessplan.util.BusinessPlanContentExtractor; import starlight.domain.aireport.entity.AiReport; import starlight.domain.aireport.exception.AiReportErrorType; import starlight.domain.aireport.exception.AiReportException; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.enumerate.PlanStatus; +import starlight.shared.enumerate.SectionType; import starlight.shared.valueobject.RawJson; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -26,12 +31,14 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -@DisplayName("AiReportServiceImpl 유닛 테스트") -class AiReportServiceImplUnitTest { +@DisplayName("AiReportService 유닛 테스트") +class AiReportServiceUnitTest { + private final BusinessPlanCommandPort businessPlanCommand = mock(BusinessPlanCommandPort.class); private final BusinessPlanQueryPort businessPlanQuery = mock(BusinessPlanQueryPort.class); - private final BusinessPlanUseCase businessPlanService = mock(BusinessPlanUseCase.class); + private final BusinessPlanCreationPort businessPlanCreationPort = mock(BusinessPlanCreationPort.class); private final AiReportQueryPort aiReportQuery = mock(AiReportQueryPort.class); + private final AiReportCommandPort aiReportCommand = mock(AiReportCommandPort.class); private final ReportGraderPort aiReportGrader = mock(ReportGraderPort.class); private final ObjectMapper objectMapper = new ObjectMapper(); private final OcrProviderPort ocrProvider = mock(OcrProviderPort.class); @@ -55,6 +62,13 @@ void gradeBusinessPlan_createsNewReport() { String extractedContent = "사업계획서 내용"; when(contentExtractor.extractContent(plan)).thenReturn(extractedContent); + + Map sectionContents = new HashMap<>(); + sectionContents.put(SectionType.PROBLEM_RECOGNITION, "문제인식 내용"); + sectionContents.put(SectionType.FEASIBILITY, "실현가능성 내용"); + sectionContents.put(SectionType.GROWTH_STRATEGY, "성장전략 내용"); + sectionContents.put(SectionType.TEAM_COMPETENCE, "팀역량 내용"); + when(contentExtractor.extractSectionContents(plan)).thenReturn(sectionContents); AiReportResult gradingResult = AiReportResult.fromGradingResult( 20, 25, 30, 20, @@ -62,7 +76,7 @@ void gradeBusinessPlan_createsNewReport() { List.of(), List.of() ); - when(aiReportGrader.gradeWithSectionAgents(extractedContent)).thenReturn(gradingResult); + when(aiReportGrader.gradeWithSectionAgents(sectionContents, extractedContent)).thenReturn(gradingResult); String rawJson = """ { @@ -79,9 +93,10 @@ void gradeBusinessPlan_createsNewReport() { when(savedReport.getId()).thenReturn(1L); when(savedReport.getBusinessPlanId()).thenReturn(planId); when(savedReport.getRawJson()).thenReturn(RawJson.create(rawJson)); - when(aiReportQuery.save(any(AiReport.class))).thenReturn(savedReport); + when(aiReportCommand.save(any(AiReport.class))).thenReturn(savedReport); + when(businessPlanCommand.save(any(BusinessPlan.class))).thenReturn(plan); - sut = new AiReportService(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommand, businessPlanQuery, businessPlanCreationPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when AiReportResult result = sut.gradeBusinessPlan(planId, memberId); @@ -89,7 +104,7 @@ void gradeBusinessPlan_createsNewReport() { // then assertThat(result).isNotNull(); verify(plan).updateStatus(PlanStatus.AI_REVIEWED); - verify(aiReportQuery).save(any(AiReport.class)); + verify(aiReportCommand).save(any(AiReport.class)); } @Test @@ -109,6 +124,13 @@ void gradeBusinessPlan_updatesExistingReport() { String extractedContent = "사업계획서 내용"; when(contentExtractor.extractContent(plan)).thenReturn(extractedContent); + + Map sectionContents = new HashMap<>(); + sectionContents.put(SectionType.PROBLEM_RECOGNITION, "문제인식 내용"); + sectionContents.put(SectionType.FEASIBILITY, "실현가능성 내용"); + sectionContents.put(SectionType.GROWTH_STRATEGY, "성장전략 내용"); + sectionContents.put(SectionType.TEAM_COMPETENCE, "팀역량 내용"); + when(contentExtractor.extractSectionContents(plan)).thenReturn(sectionContents); AiReportResult gradingResult = AiReportResult.fromGradingResult( 20, 25, 30, 20, @@ -116,7 +138,7 @@ void gradeBusinessPlan_updatesExistingReport() { List.of(), List.of() ); - when(aiReportGrader.gradeWithSectionAgents(extractedContent)).thenReturn(gradingResult); + when(aiReportGrader.gradeWithSectionAgents(sectionContents, extractedContent)).thenReturn(gradingResult); String rawJson = """ { @@ -132,9 +154,10 @@ void gradeBusinessPlan_updatesExistingReport() { when(existingReport.getId()).thenReturn(1L); when(existingReport.getBusinessPlanId()).thenReturn(planId); when(existingReport.getRawJson()).thenReturn(RawJson.create(rawJson)); - when(aiReportQuery.save(existingReport)).thenReturn(existingReport); + when(aiReportCommand.save(existingReport)).thenReturn(existingReport); + when(businessPlanCommand.save(any(BusinessPlan.class))).thenReturn(plan); - sut = new AiReportService(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommand, businessPlanQuery, businessPlanCreationPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when AiReportResult result = sut.gradeBusinessPlan(planId, memberId); @@ -156,7 +179,7 @@ void gradeBusinessPlan_throwsExceptionWhenNotOwner() { when(plan.isOwnedBy(memberId)).thenReturn(false); when(businessPlanQuery.findByIdOrThrow(planId)).thenReturn(plan); - sut = new AiReportService(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommand, businessPlanQuery, businessPlanCreationPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) @@ -176,7 +199,7 @@ void gradeBusinessPlan_throwsExceptionWhenNotCompleted() { when(plan.areWritingCompleted()).thenReturn(false); when(businessPlanQuery.findByIdOrThrow(planId)).thenReturn(plan); - sut = new AiReportService(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommand, businessPlanQuery, businessPlanCreationPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) @@ -214,7 +237,7 @@ void getAiReport_returnsResponse() { when(aiReport.getRawJson()).thenReturn(RawJson.create(rawJson)); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.of(aiReport)); - sut = new AiReportService(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommand, businessPlanQuery, businessPlanCreationPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when AiReportResult result = sut.getAiReport(planId, memberId); @@ -238,7 +261,7 @@ void getAiReport_throwsExceptionWhenNotFound() { when(businessPlanQuery.findByIdOrThrow(planId)).thenReturn(plan); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.empty()); - sut = new AiReportService(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommand, businessPlanQuery, businessPlanCreationPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.getAiReport(planId, memberId)) diff --git a/src/test/java/starlight/adapter/ai/util/AiReportResponseParserTest.java b/src/test/java/starlight/application/aireport/util/AiReportResponseParserTest.java similarity index 98% rename from src/test/java/starlight/adapter/ai/util/AiReportResponseParserTest.java rename to src/test/java/starlight/application/aireport/util/AiReportResponseParserTest.java index 2b0267e..80fff9e 100644 --- a/src/test/java/starlight/adapter/ai/util/AiReportResponseParserTest.java +++ b/src/test/java/starlight/application/aireport/util/AiReportResponseParserTest.java @@ -1,9 +1,9 @@ -package starlight.adapter.ai.util; +package starlight.application.aireport.util; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import starlight.adapter.aireport.report.util.AiReportResponseParser; +import starlight.application.aireport.util.AiReportResponseParser; import starlight.application.aireport.provided.dto.AiReportResult; import starlight.domain.aireport.exception.AiReportException; import starlight.domain.aireport.exception.AiReportErrorType; diff --git a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceIntegrationTest.java similarity index 97% rename from src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java rename to src/test/java/starlight/application/businessplan/BusinessPlanServiceIntegrationTest.java index 34f6772..802cd2e 100644 --- a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java +++ b/src/test/java/starlight/application/businessplan/BusinessPlanServiceIntegrationTest.java @@ -27,8 +27,8 @@ @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) @Import({ BusinessPlanService.class, BusinessPlanJpa.class, - BusinessPlanServiceImplIntegrationTest.TestBeans.class }) -class BusinessPlanServiceImplIntegrationTest { + BusinessPlanServiceIntegrationTest.TestBeans.class }) +class BusinessPlanServiceIntegrationTest { @Autowired BusinessPlanService sut; diff --git a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java similarity index 94% rename from src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java rename to src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java index c9a4bd8..b7a09fc 100644 --- a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java +++ b/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java @@ -11,6 +11,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import starlight.application.businessplan.provided.dto.BusinessPlanResult; import starlight.application.businessplan.provided.dto.SubSectionResult; +import starlight.application.businessplan.required.BusinessPlanCommandPort; import starlight.application.businessplan.required.BusinessPlanQueryPort; import starlight.application.businessplan.required.ChecklistGraderPort; import starlight.domain.businessplan.entity.BusinessPlan; @@ -34,7 +35,10 @@ @ExtendWith(MockitoExtension.class) @org.mockito.junit.jupiter.MockitoSettings(strictness = org.mockito.quality.Strictness.LENIENT) -class BusinessPlanServiceImplUnitTest { +class BusinessPlanServiceUnitTest { + + @Mock + private BusinessPlanCommandPort businessPlanCommand; @Mock private BusinessPlanQueryPort businessPlanQuery; @@ -72,20 +76,20 @@ void setup() { @Test @DisplayName("사업계획서 생성 시 루트가 저장된다") void createBusinessPlan_savesRoot() { - when(businessPlanQuery.save(any(BusinessPlan.class))) + when(businessPlanCommand.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); BusinessPlanResult.Result created = sut.createBusinessPlan(1L); assertThat(created).isNotNull(); assertThat(created.message()).isEqualTo("Business plan created"); - verify(businessPlanQuery).save(any(BusinessPlan.class)); + verify(businessPlanCommand).save(any(BusinessPlan.class)); } @Test @DisplayName("PDF URL을 기반으로 사업계획서를 생성하면 저장된다") void createBusinessPlanWithPdf_savesRoot() { - when(businessPlanQuery.save(any(BusinessPlan.class))) + when(businessPlanCommand.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); String title = "테스트 사업계획서"; @@ -97,7 +101,7 @@ void createBusinessPlanWithPdf_savesRoot() { assertThat(created).isNotNull(); assertThat(created.message()).isEqualTo("PDF Business plan created"); assertThat(created.title()).isEqualTo(title); - verify(businessPlanQuery).save(any(BusinessPlan.class)); + verify(businessPlanCommand).save(any(BusinessPlan.class)); } @Test @@ -106,13 +110,13 @@ void updateTitle_checksOwnership_thenSaves() { BusinessPlan plan = spy(buildPlanWithSections(10L)); doReturn(true).when(plan).isOwnedBy(10L); when(businessPlanQuery.findByIdOrThrow(100L)).thenReturn(plan); - when(businessPlanQuery.save(any(BusinessPlan.class))) + when(businessPlanCommand.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); String updatedTitle = sut.updateBusinessPlanTitle(100L, "new-title", 10L); assertThat(updatedTitle).isEqualTo("new-title"); - verify(businessPlanQuery).save(plan); + verify(businessPlanCommand).save(plan); } @Test @@ -140,7 +144,7 @@ void deleteBusinessPlan_cascadeDeletesSubSections() { assertThat(deleted.businessPlanId()).isEqualTo(100L); assertThat(deleted.message()).isEqualTo("Business plan deleted"); - verify(businessPlanQuery).delete(plan); + verify(businessPlanCommand).delete(plan); } @Test @@ -151,7 +155,7 @@ void upsertSubSection_creates_whenNotExists() { Overview overview = plan.getOverview(); when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); - when(businessPlanQuery.save(any(BusinessPlan.class))) + when(businessPlanCommand.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper() @@ -175,7 +179,7 @@ void upsertSubSection_creates_whenNotExists() { assertThat(overview.getSubSectionByType(SubSectionType.OVERVIEW_BASIC)).isNotNull(); assertThat(overview.getSubSectionByType(SubSectionType.OVERVIEW_BASIC).getSubSectionType()) .isEqualTo(SubSectionType.OVERVIEW_BASIC); - verify(businessPlanQuery).save(plan); + verify(businessPlanCommand).save(plan); } @Test @@ -190,7 +194,7 @@ void upsertSubSection_updates_whenExists() { overview.putSubSection(existing); when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); - when(businessPlanQuery.save(any(BusinessPlan.class))) + when(businessPlanCommand.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper() @@ -207,7 +211,7 @@ void upsertSubSection_updates_whenExists() { SubSectionType.OVERVIEW_BASIC, 10L); assertThat(res.message()).isEqualTo("Subsection updated"); - verify(businessPlanQuery).save(plan); + verify(businessPlanCommand).save(plan); } @Test @@ -277,7 +281,7 @@ void deleteSubSection_success() { overview.putSubSection(sub); when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); - when(businessPlanQuery.save(any(BusinessPlan.class))) + when(businessPlanCommand.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); SubSectionResult.Result res = sut.deleteSubSection(1L, SubSectionType.OVERVIEW_BASIC, 10L); @@ -287,7 +291,7 @@ void deleteSubSection_success() { assertThat(res.subSectionId()).isNull(); assertThat(res.message()).isEqualTo("Subsection deleted"); assertThat(overview.getSubSectionByType(SubSectionType.OVERVIEW_BASIC)).isNull(); - verify(businessPlanQuery).save(plan); + verify(businessPlanCommand).save(plan); } @Test @@ -372,7 +376,7 @@ void checkAndUpdateSubSection_savesChecks() { overview.putSubSection(sub); when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); - when(businessPlanQuery.save(any(BusinessPlan.class))) + when(businessPlanCommand.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); List updatedChecks = List.of(true, true, true, true, true); @@ -404,7 +408,7 @@ void checkAndUpdateSubSection_savesChecks() { assertThat(result).containsExactlyElementsOf(updatedChecks); assertThat(sub.getChecks()).containsExactlyElementsOf(updatedChecks); assertThat(sub.getContent()).isEqualTo("updated content"); - verify(businessPlanQuery).save(plan); + verify(businessPlanCommand).save(plan); } @Test @@ -435,7 +439,7 @@ void checkAndUpdateSubSection_unauthorized_throws() { void createSubSection_forEachSectionType() { BusinessPlan plan = buildPlanWithSections(10L); when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); - when(businessPlanQuery.save(any(BusinessPlan.class))) + when(businessPlanCommand.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper() @@ -484,7 +488,7 @@ void upsertSubSection_allSubSectionsCreated_updatesStatusToDrafted() { } when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); - when(businessPlanQuery.save(any(BusinessPlan.class))) + when(businessPlanCommand.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper() @@ -512,7 +516,7 @@ void upsertSubSection_partialSubSections_noStatusChange() { doReturn(true).when(plan).isOwnedBy(10L); when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); - when(businessPlanQuery.save(any(BusinessPlan.class))) + when(businessPlanCommand.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); com.fasterxml.jackson.databind.node.ObjectNode jsonNode = new com.fasterxml.jackson.databind.ObjectMapper() @@ -546,7 +550,7 @@ void deleteSubSection_noStatusChange() { } when(businessPlanQuery.findByIdOrThrow(1L)).thenReturn(plan); - when(businessPlanQuery.save(any(BusinessPlan.class))) + when(businessPlanCommand.save(any(BusinessPlan.class))) .thenAnswer(invocation -> invocation.getArgument(0)); // when - 서브섹션 삭제 diff --git a/src/test/java/starlight/application/member/CredentialServiceImplIntegrationTest.java b/src/test/java/starlight/application/member/CredentialServiceIntegrationTest.java similarity index 95% rename from src/test/java/starlight/application/member/CredentialServiceImplIntegrationTest.java rename to src/test/java/starlight/application/member/CredentialServiceIntegrationTest.java index 336f57b..287b389 100644 --- a/src/test/java/starlight/application/member/CredentialServiceImplIntegrationTest.java +++ b/src/test/java/starlight/application/member/CredentialServiceIntegrationTest.java @@ -17,8 +17,8 @@ import static org.mockito.Mockito.when; @ExtendWith(SpringExtension.class) -@ContextConfiguration(classes = {CredentialService.class, CredentialServiceImplIntegrationTest.TestBeans.class}) -class CredentialServiceImplIntegrationTest { +@ContextConfiguration(classes = {CredentialService.class, CredentialServiceIntegrationTest.TestBeans.class}) +class CredentialServiceIntegrationTest { @TestConfiguration static class TestBeans { diff --git a/src/test/java/starlight/application/member/CredentialServiceImplUnitTest.java b/src/test/java/starlight/application/member/CredentialServiceUnitTest.java similarity index 98% rename from src/test/java/starlight/application/member/CredentialServiceImplUnitTest.java rename to src/test/java/starlight/application/member/CredentialServiceUnitTest.java index 2ddc291..08597bf 100644 --- a/src/test/java/starlight/application/member/CredentialServiceImplUnitTest.java +++ b/src/test/java/starlight/application/member/CredentialServiceUnitTest.java @@ -14,7 +14,7 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) -class CredentialServiceImplUnitTest { +class CredentialServiceUnitTest { @Mock PasswordEncoder passwordEncoder; @InjectMocks diff --git a/src/test/java/starlight/application/member/MemberQueryServiceIntegrationTest.java b/src/test/java/starlight/application/member/MemberServiceIntegrationTest.java similarity index 98% rename from src/test/java/starlight/application/member/MemberQueryServiceIntegrationTest.java rename to src/test/java/starlight/application/member/MemberServiceIntegrationTest.java index f24664c..01a0d72 100644 --- a/src/test/java/starlight/application/member/MemberQueryServiceIntegrationTest.java +++ b/src/test/java/starlight/application/member/MemberServiceIntegrationTest.java @@ -19,7 +19,7 @@ @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) @Import({MemberService.class, MemberJpa.class}) -class MemberQueryServiceIntegrationTest { +class MemberServiceIntegrationTest { @Autowired MemberService sut; diff --git a/src/test/java/starlight/application/member/MemberQueryServiceUnitTest.java b/src/test/java/starlight/application/member/MemberServiceUnitTest.java similarity index 98% rename from src/test/java/starlight/application/member/MemberQueryServiceUnitTest.java rename to src/test/java/starlight/application/member/MemberServiceUnitTest.java index c2ee2cf..dcfaec2 100644 --- a/src/test/java/starlight/application/member/MemberQueryServiceUnitTest.java +++ b/src/test/java/starlight/application/member/MemberServiceUnitTest.java @@ -18,7 +18,7 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) -class MemberQueryServiceUnitTest { +class MemberServiceUnitTest { @Mock MemberQueryPort memberQueryPort; @Mock MemberCommandPort memberCommandPort; From a54b7de577f6beb37ad8e3f9ea2009a283992052 Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Fri, 9 Jan 2026 08:45:36 -0600 Subject: [PATCH 27/74] =?UTF-8?q?[SRLT-124]=20Refactor:=20SectionType=20?= =?UTF-8?q?=EB=84=90=20=ED=83=80=EC=9E=85=20=EC=95=88=EC=A0=95=EC=84=B1=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/starlight/shared/enumerate/SectionType.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/starlight/shared/enumerate/SectionType.java b/src/main/java/starlight/shared/enumerate/SectionType.java index 6ce0432..6a7ede3 100644 --- a/src/main/java/starlight/shared/enumerate/SectionType.java +++ b/src/main/java/starlight/shared/enumerate/SectionType.java @@ -25,7 +25,7 @@ public enum SectionType { * AiReportResponse에서 해당 섹션의 score를 추출 */ public Integer extractScore(AiReportResult response) { - if (scoreExtractor == null) { + if (scoreExtractor == null || response == null) { return 0; } return scoreExtractor.apply(response); From 3761c48f173460461a251947e70c6d7afa9715d6 Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Mon, 12 Jan 2026 11:50:34 -0600 Subject: [PATCH 28/74] =?UTF-8?q?[SRLT-124]=20Refactor:=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=97=90=20LLM=20=EC=9D=91=EB=8B=B5=20=EC=9B=90?= =?UTF-8?q?=EB=AC=B8=20=EB=B0=8F=20=EC=9D=BC=EB=B6=80=EA=B0=80=20=EB=93=A4?= =?UTF-8?q?=EC=96=B4=EA=B0=80=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aireport/util/AiReportResponseParser.java | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/main/java/starlight/application/aireport/util/AiReportResponseParser.java b/src/main/java/starlight/application/aireport/util/AiReportResponseParser.java index c2b5808..581ab3a 100644 --- a/src/main/java/starlight/application/aireport/util/AiReportResponseParser.java +++ b/src/main/java/starlight/application/aireport/util/AiReportResponseParser.java @@ -124,7 +124,6 @@ private boolean isDefaultResponse(AiReportResult response) { * 4개의 전체 점수 필드를 모두 요구 */ public AiReportResult parse(String llmResponse) { - log.debug("Raw LLM response: {}", llmResponse); // 1. 기본 검증 if (llmResponse == null || llmResponse.trim().isEmpty()) { @@ -135,7 +134,6 @@ public AiReportResult parse(String llmResponse) { try { // 2. JSON 문자열 정리 String cleanedJson = cleanJsonResponse(llmResponse); - log.debug("Cleaned JSON: {}", cleanedJson); // 3. JSON 파싱 시도 JsonNode jsonNode = objectMapper.readTree(cleanedJson); @@ -159,7 +157,6 @@ public AiReportResult parse(String llmResponse) { return response; } catch (Exception e) { - log.error("Failed to parse LLM response. Response: {}", llmResponse, e); throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); } } @@ -170,7 +167,6 @@ public AiReportResult parse(String llmResponse) { * 예: {"feasibilityScore": 0, "sectionScores": [...]} */ public AiReportResult parseSectionResponse(String llmResponse) { - log.debug("Raw section LLM response: {}", llmResponse); // 1. 기본 검증 if (llmResponse == null || llmResponse.trim().isEmpty()) { @@ -181,7 +177,6 @@ public AiReportResult parseSectionResponse(String llmResponse) { try { // 2. JSON 문자열 정리 String cleanedJson = cleanJsonResponse(llmResponse); - log.debug("Cleaned section JSON: {}", cleanedJson); // 3. JSON 파싱 시도 JsonNode jsonNode = objectMapper.readTree(cleanedJson); @@ -209,7 +204,6 @@ public AiReportResult parseSectionResponse(String llmResponse) { // 최소 하나의 점수 필드는 있어야 함 if (problemRecognitionScore == null && feasibilityScore == null && growthStrategyScore == null && teamCompetenceScore == null) { - log.error("No section score field found in response"); throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); } @@ -229,7 +223,6 @@ public AiReportResult parseSectionResponse(String llmResponse) { ); } catch (Exception e) { - log.error("Failed to parse section LLM response. Response: {}", llmResponse, e); throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); } } @@ -279,10 +272,8 @@ private String cleanJsonResponse(String json) { .replace("\\\"", "\"") .replace("\\\\", "\\"); cleaned = extracted; - log.debug("Extracted text field using regex"); } } catch (Exception e2) { - log.warn("Failed to extract text field using regex: {}", e2.getMessage()); } } } @@ -457,8 +448,6 @@ private List parseSectionScores(JsonN objectMapper.readTree(gradingListScores); } } catch (Exception e) { - log.warn("Failed to parse gradingListScores for sectionType: {}, using default. Value: {}", - sectionType, gradingListScores); gradingListScores = "[]"; } } @@ -466,7 +455,6 @@ private List parseSectionScores(JsonN list.add(new AiReportResult.SectionScoreDetailResponse(sectionType, gradingListScores)); } catch (Exception e) { log.warn("Failed to parse sectionScore item, skipping: {}", e.getMessage()); - // 불완전한 항목은 건너뛰기 } } } From fa0f19861276652bcb1b5da70ccca95d6d6706ff Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Mon, 12 Jan 2026 22:13:22 -0600 Subject: [PATCH 29/74] =?UTF-8?q?[SRLT-124]=20Refactor:=20upsert=20?= =?UTF-8?q?=EA=B2=BD=EC=9F=81=20=EC=A1=B0=EA=B1=B4(TOCTOU)=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../starlight/application/aireport/AiReportService.java | 7 ++++--- .../java/starlight/domain/aireport/entity/AiReport.java | 8 ++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/main/java/starlight/application/aireport/AiReportService.java b/src/main/java/starlight/application/aireport/AiReportService.java index b700e7e..e4b7ce6 100644 --- a/src/main/java/starlight/application/aireport/AiReportService.java +++ b/src/main/java/starlight/application/aireport/AiReportService.java @@ -78,7 +78,7 @@ public AiReportResult gradeBusinessPlan(Long planId, Long memberId) { AiReport aiReport = upsertAiReportWithRawJsonStr(rawJsonString, plan); - return responseParser.toResponse(aiReportCommandPort.save(aiReport)); + return responseParser.toResponse(aiReport); } @Override @@ -112,7 +112,7 @@ public AiReportResult createAndGradePdfBusinessPlan(String title, String pdfUrl, AiReport aiReport = upsertAiReportWithRawJsonStr(rawJsonString, plan); - return responseParser.toResponse(aiReportCommandPort.save(aiReport)); + return responseParser.toResponse(aiReport); } @Override @@ -148,10 +148,11 @@ private AiReport upsertAiReportWithRawJsonStr(String rawJsonString, BusinessPlan } else { aiReport = AiReport.create(plan.getId(), rawJsonString); } + plan.updateStatus(PlanStatus.AI_REVIEWED); businessPlanCommandPort.save(plan); - return aiReport; + return aiReportCommandPort.save(aiReport); } private void checkBusinessPlanOwned(BusinessPlan plan, Long memberId) { diff --git a/src/main/java/starlight/domain/aireport/entity/AiReport.java b/src/main/java/starlight/domain/aireport/entity/AiReport.java index 234053c..3af44f5 100644 --- a/src/main/java/starlight/domain/aireport/entity/AiReport.java +++ b/src/main/java/starlight/domain/aireport/entity/AiReport.java @@ -11,6 +11,14 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + uniqueConstraints = { + @UniqueConstraint( + name = "uk_ai_report_business_plan", + columnNames = {"business_plan_id"} + ) + } +) public class AiReport extends AbstractEntity { @Column(name = "business_plan_id", nullable = false) From 6b747c19fbfdde914e8794763444516599e97f49 Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Tue, 13 Jan 2026 13:15:00 -0600 Subject: [PATCH 30/74] =?UTF-8?q?[SRLT-124]=20Refactor:=20SectionType=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=9C=84=EB=B0=B0=EB=A5=BC=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=ED=95=98=EA=B8=B0=20=EC=9C=84=ED=95=B4=20Sec?= =?UTF-8?q?tionScoreExtractor=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/impl/SpringAiSectionGradeAgent.java | 18 +++++----- .../aireport/util/SectionScoreExtractor.java | 35 +++++++++++++++++++ .../shared/enumerate/SectionType.java | 25 +++---------- 3 files changed, 48 insertions(+), 30 deletions(-) create mode 100644 src/main/java/starlight/application/aireport/util/SectionScoreExtractor.java 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 0a852ad..ee6fe28 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 @@ -13,6 +13,7 @@ import starlight.adapter.aireport.report.provider.SpringAiAdvisorProvider; import starlight.adapter.aireport.report.provider.ReportPromptProvider; import starlight.application.aireport.util.AiReportResponseParser; +import starlight.application.aireport.util.SectionScoreExtractor; import starlight.application.aireport.provided.dto.AiReportResult; import starlight.shared.enumerate.SectionType; @@ -88,18 +89,15 @@ private SectionGradingResult parseSectionResult(String llmResponse) { // 섹션별 응답 파싱 메소드 사용 AiReportResult sectionResponse = responseParser.parseSectionResponse(llmResponse); - // SectionType의 score 추출 메서드 사용 - Integer score = getSectionType().extractScore(sectionResponse); + // SectionScoreExtractor를 사용하여 점수 추출 + Integer score = SectionScoreExtractor.extractScore(getSectionType(), sectionResponse); // sectionScores에서 해당 섹션 찾기 - String sectionTypeString = getSectionType().getSectionTypeString(); - AiReportResult.SectionScoreDetailResponse sectionScore = null; - if (sectionTypeString != null) { - sectionScore = sectionResponse.sectionScores().stream() - .filter(ss -> sectionTypeString.equals(ss.sectionType())) - .findFirst() - .orElse(null); - } + String sectionTypeString = getSectionType().name(); + AiReportResult.SectionScoreDetailResponse sectionScore = sectionResponse.sectionScores().stream() + .filter(ss -> sectionTypeString.equals(ss.sectionType())) + .findFirst() + .orElse(null); return SectionGradingResult.success(getSectionType(), score, sectionScore); diff --git a/src/main/java/starlight/application/aireport/util/SectionScoreExtractor.java b/src/main/java/starlight/application/aireport/util/SectionScoreExtractor.java new file mode 100644 index 0000000..e92917a --- /dev/null +++ b/src/main/java/starlight/application/aireport/util/SectionScoreExtractor.java @@ -0,0 +1,35 @@ +package starlight.application.aireport.util; + +import starlight.application.aireport.provided.dto.AiReportResult; +import starlight.shared.enumerate.SectionType; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +public class SectionScoreExtractor { + + private static final Map> SCORE_EXTRACTORS = new HashMap<>(); + + static { + SCORE_EXTRACTORS.put(SectionType.PROBLEM_RECOGNITION, AiReportResult::problemRecognitionScore); + SCORE_EXTRACTORS.put(SectionType.FEASIBILITY, AiReportResult::feasibilityScore); + SCORE_EXTRACTORS.put(SectionType.GROWTH_STRATEGY, AiReportResult::growthStrategyScore); + SCORE_EXTRACTORS.put(SectionType.TEAM_COMPETENCE, AiReportResult::teamCompetenceScore); + } + + public static Integer extractScore(SectionType sectionType, AiReportResult result) { + if (sectionType == null || result == null) { + return 0; + } + + Function extractor = SCORE_EXTRACTORS.get(sectionType); + if (extractor == null) { + return 0; + } + + Integer score = extractor.apply(result); + return score != null ? score : 0; + } +} + diff --git a/src/main/java/starlight/shared/enumerate/SectionType.java b/src/main/java/starlight/shared/enumerate/SectionType.java index 6a7ede3..97c9454 100644 --- a/src/main/java/starlight/shared/enumerate/SectionType.java +++ b/src/main/java/starlight/shared/enumerate/SectionType.java @@ -2,32 +2,17 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; -import starlight.application.aireport.provided.dto.AiReportResult; - -import java.util.function.Function; @Getter @RequiredArgsConstructor public enum SectionType { - OVERVIEW("개요", null, null, null), - PROBLEM_RECOGNITION("문제 인식", "problem_recognition", "PROBLEM_RECOGNITION", AiReportResult::problemRecognitionScore), - FEASIBILITY("실현 가능성", "feasibility", "FEASIBILITY", AiReportResult::feasibilityScore), - GROWTH_STRATEGY("성장 전략", "growth_strategy", "GROWTH_STRATEGY", AiReportResult::growthStrategyScore), - TEAM_COMPETENCE("팀 역량", "team_competence", "TEAM_COMPETENCE", AiReportResult::teamCompetenceScore); + OVERVIEW("개요", null), + PROBLEM_RECOGNITION("문제 인식", "problem_recognition"), + FEASIBILITY("실현 가능성", "feasibility"), + GROWTH_STRATEGY("성장 전략", "growth_strategy"), + TEAM_COMPETENCE("팀 역량", "team_competence"); private final String description; private final String tag; - private final String sectionTypeString; // sectionScores에서 사용할 문자열 - private final Function scoreExtractor; // score 추출 함수 - - /** - * AiReportResponse에서 해당 섹션의 score를 추출 - */ - public Integer extractScore(AiReportResult response) { - if (scoreExtractor == null || response == null) { - return 0; - } - return scoreExtractor.apply(response); - } } \ No newline at end of file From 78962896298e2ae7578b86de1aeb18017a6ec85d Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Tue, 13 Jan 2026 13:59:17 -0600 Subject: [PATCH 31/74] =?UTF-8?q?[SRLT-124]=20Refactor:=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=202=EB=B6=84=20=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83?= =?UTF-8?q?=20=20+=20=ED=83=80=EC=9E=84=EC=95=84=EC=9B=83=20=EC=8B=9C=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CompletableFuture.allOf()를 통해 병렬처리 결과를 한번에 받을 수 있도록 하게 함 - 타임아웃 발생시에 cancel()을 통해 스레드 자원을 해제시키도록 함 --- .../aireport/report/SpringAiReportGrader.java | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java b/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java index f2eb09b..e66829d 100644 --- a/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java +++ b/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java @@ -126,20 +126,40 @@ public AiReportResult gradeWithSectionAgents(Map sectionCon })); // 모든 채점 완료 대기 (최대 2분) + CompletableFuture[] futures = futureMap.values().toArray(new CompletableFuture[0]); + CompletableFuture allFutures = CompletableFuture.allOf(futures); + + try { + allFutures.get(2, TimeUnit.MINUTES); + } catch (java.util.concurrent.TimeoutException e) { + log.warn("섹션별 채점 타임아웃 발생. 모든 Future 취소하여 스레드 자원 해제 중..."); + for (CompletableFuture future : futureMap.values()) { + if (!future.isDone()) { + future.cancel(true); + } + } + } catch (Exception e) { + log.error("섹션별 채점 중 예외 발생", e); + for (CompletableFuture future : futureMap.values()) { + if (!future.isDone()) { + future.cancel(true); + } + } + } + + // 결과 수집 List results = futureMap.entrySet().stream() .map(entry -> { SectionType sectionType = entry.getKey(); CompletableFuture future = entry.getValue(); try { - SectionGradingResult result = future.get(2, TimeUnit.MINUTES); - log.debug("[{}] 섹션 채점 완료. 성공: {}, 점수: {}", - sectionType, result.success(), result.score()); - return result; + if (future.isCancelled()) { + return SectionGradingResult.failure(sectionType, "타임아웃"); + } + return future.get(0, TimeUnit.SECONDS); } catch (java.util.concurrent.TimeoutException e) { - log.error("[{}] 섹션 채점 타임아웃 (2분 초과)", sectionType); return SectionGradingResult.failure(sectionType, "타임아웃"); } catch (Exception e) { - log.error("[{}] 섹션 채점 Future 완료 실패", sectionType, e); return SectionGradingResult.failure(sectionType, "예외: " + e.getMessage()); } }) From bf27c6c2b926b39e3e169ec58db132cc1007eaec Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Tue, 13 Jan 2026 22:26:30 -0600 Subject: [PATCH 32/74] =?UTF-8?q?[SRLT-124]=20Refactor:=20SectionType=20?= =?UTF-8?q?=EC=97=90=EC=9D=B4=EC=A0=84=ED=8A=B8=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aireport/report/SpringAiReportGrader.java | 15 +++++++++++---- .../aireport/exception/AiReportErrorType.java | 3 ++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java b/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java index e66829d..fa1fe7b 100644 --- a/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java +++ b/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java @@ -10,6 +10,8 @@ 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; import java.util.*; @@ -38,10 +40,15 @@ public SpringAiReportGrader( SpringAiReportSupervisor supervisor, BusinessPlanContentExtractor contentExtractor, @Qualifier("sectionGradingExecutor") Executor sectionGradingExecutor) { - this.sectionGradeAgentMap = sectionGradeAgentList.stream() - .collect(Collectors.toMap( - SectionGradeAgent::getSectionType, - advisor -> advisor)); + try { + this.sectionGradeAgentMap = sectionGradeAgentList.stream() + .collect(Collectors.toMap( + SectionGradeAgent::getSectionType, + advisor -> advisor)); + } catch (IllegalStateException e) { + log.error("중복된 SectionType 에이전트로 인한 에러", e); + throw new AiReportException(AiReportErrorType.AI_AGENT_DUPLICATED); + } this.fullReportGradeAgent = fullReportGradeAgent; this.supervisor = supervisor; this.contentExtractor = contentExtractor; diff --git a/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java b/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java index a8386e1..56353ae 100644 --- a/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java +++ b/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java @@ -13,7 +13,8 @@ public enum AiReportErrorType implements ErrorType { NOT_READY_FOR_AI_REPORT(HttpStatus.BAD_REQUEST, "사업계획서가 작성 완료되지 않아 AI 리포트를 생성할 수 없습니다."), UNAUTHORIZED_ACCESS(HttpStatus.FORBIDDEN, "권한이 없습니다."), AI_RESPONSE_PARSING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AI 응답 파싱에 실패했습니다."), - AI_GRADING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AI 채점에 실패했습니다."); + AI_GRADING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AI 채점에 실패했습니다."), + AI_AGENT_DUPLICATED(HttpStatus.INTERNAL_SERVER_ERROR, "AI 리포트 에이전트가 중복입니다."); ; private final HttpStatus status; From d6287475a1ee4d3b87439110e3a14bce55fd9408 Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Tue, 13 Jan 2026 22:31:08 -0600 Subject: [PATCH 33/74] =?UTF-8?q?[SRLT-124]=20Chore:=20=EB=94=94=ED=8F=B4?= =?UTF-8?q?=ED=8A=B8=20=EB=A9=94=EC=86=8C=EB=93=9C=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?=ED=9B=84=20private=20=EB=A9=94=EC=86=8C=EB=93=9C=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aireport/report/agent/SectionGradeAgent.java | 14 -------------- .../agent/impl/SpringAiSectionGradeAgent.java | 11 +++++++++++ 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/main/java/starlight/adapter/aireport/report/agent/SectionGradeAgent.java b/src/main/java/starlight/adapter/aireport/report/agent/SectionGradeAgent.java index 1b3b6a2..2119e18 100644 --- a/src/main/java/starlight/adapter/aireport/report/agent/SectionGradeAgent.java +++ b/src/main/java/starlight/adapter/aireport/report/agent/SectionGradeAgent.java @@ -8,20 +8,6 @@ public interface SectionGradeAgent { SectionType getSectionType(); SectionGradingResult gradeSection(String sectionContent); - - /** - * SectionType의 tag를 기반으로 filter expression 생성 - */ - default String buildFilterExpression() { - SectionType sectionType = getSectionType(); - String tag = sectionType.getTag(); - - if (tag == null || tag.isBlank()) { - return null; - } - - return "tag == '" + tag + "'"; - } } 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 ee6fe28..f98abad 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 @@ -84,6 +84,17 @@ public SectionGradingResult gradeSection(String sectionContent) { } } + private String buildFilterExpression() { + SectionType sectionType = getSectionType(); + String tag = sectionType.getTag(); + + if (tag == null || tag.isBlank()) { + return null; + } + + return "tag == '" + tag + "'"; + } + private SectionGradingResult parseSectionResult(String llmResponse) { try { // 섹션별 응답 파싱 메소드 사용 From 17b687d4c41db0642e77df22c94dd0ddeece590b Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Fri, 16 Jan 2026 00:49:27 +0900 Subject: [PATCH 34/74] =?UTF-8?q?[SRLT-132]=20feat:=20=EB=B0=B1=EC=98=A4?= =?UTF-8?q?=ED=94=BC=EC=8A=A4=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config | 2 +- .../backoffice/mail/email/SmtpMailSender.java | 46 ++++++++++ .../persistence/BackofficeMailSendLogJpa.java | 18 ++++ .../BackofficeMailSendLogRepository.java | 7 ++ .../BackofficeMailTemplateJpa.java | 31 +++++++ .../BackofficeMailTemplateRepository.java | 7 ++ .../mail/webapi/BackofficeMailController.java | 52 +++++++++++ .../request/BackofficeMailSendRequest.java | 34 ++++++++ .../BackofficeMailTemplateCreateRequest.java | 26 ++++++ .../BackofficeMailSendLogResponse.java | 23 +++++ .../BackofficeMailTemplateResponse.java | 27 ++++++ .../mail/BackofficeMailLogService.java | 55 ++++++++++++ .../mail/BackofficeMailSendService.java | 87 +++++++++++++++++++ .../mail/BackofficeMailTemplateService.java | 77 ++++++++++++++++ .../provided/BackofficeMailLogUseCase.java | 9 ++ .../provided/BackofficeMailSendUseCase.java | 9 ++ .../BackofficeMailTemplateUseCase.java | 15 ++++ .../dto/input/BackofficeMailSendInput.java | 12 +++ .../BackofficeMailSendLogCreateInput.java | 14 +++ .../BackofficeMailTemplateCreateInput.java | 12 +++ .../result/BackofficeMailSendLogResult.java | 14 +++ .../result/BackofficeMailTemplateResult.java | 14 +++ .../BackofficeMailSendLogCommandPort.java | 8 ++ .../BackofficeMailTemplateCommandPort.java | 10 +++ .../BackofficeMailTemplateQueryPort.java | 10 +++ .../mail/required/MailSenderPort.java | 9 ++ .../starlight/bootstrap/SecurityConfig.java | 2 + .../exception/BackofficeErrorType.java | 24 +++++ .../exception/BackofficeException.java | 11 +++ .../mail/BackofficeMailContentType.java | 26 ++++++ .../mail/BackofficeMailSendLog.java | 50 +++++++++++ .../mail/BackofficeMailTemplate.java | 50 +++++++++++ 32 files changed, 790 insertions(+), 1 deletion(-) create mode 100644 src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java create mode 100644 src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailSendLogJpa.java create mode 100644 src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailSendLogRepository.java create mode 100644 src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailTemplateJpa.java create mode 100644 src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailTemplateRepository.java create mode 100644 src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java create mode 100644 src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailSendRequest.java create mode 100644 src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailTemplateCreateRequest.java create mode 100644 src/main/java/starlight/adapter/backoffice/mail/webapi/dto/response/BackofficeMailSendLogResponse.java create mode 100644 src/main/java/starlight/adapter/backoffice/mail/webapi/dto/response/BackofficeMailTemplateResponse.java create mode 100644 src/main/java/starlight/application/backoffice/mail/BackofficeMailLogService.java create mode 100644 src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java create mode 100644 src/main/java/starlight/application/backoffice/mail/BackofficeMailTemplateService.java create mode 100644 src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailLogUseCase.java create mode 100644 src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailSendUseCase.java create mode 100644 src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailTemplateUseCase.java create mode 100644 src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailSendInput.java create mode 100644 src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailSendLogCreateInput.java create mode 100644 src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailTemplateCreateInput.java create mode 100644 src/main/java/starlight/application/backoffice/mail/provided/dto/result/BackofficeMailSendLogResult.java create mode 100644 src/main/java/starlight/application/backoffice/mail/provided/dto/result/BackofficeMailTemplateResult.java create mode 100644 src/main/java/starlight/application/backoffice/mail/required/BackofficeMailSendLogCommandPort.java create mode 100644 src/main/java/starlight/application/backoffice/mail/required/BackofficeMailTemplateCommandPort.java create mode 100644 src/main/java/starlight/application/backoffice/mail/required/BackofficeMailTemplateQueryPort.java create mode 100644 src/main/java/starlight/application/backoffice/mail/required/MailSenderPort.java create mode 100644 src/main/java/starlight/domain/backoffice/exception/BackofficeErrorType.java create mode 100644 src/main/java/starlight/domain/backoffice/exception/BackofficeException.java create mode 100644 src/main/java/starlight/domain/backoffice/mail/BackofficeMailContentType.java create mode 100644 src/main/java/starlight/domain/backoffice/mail/BackofficeMailSendLog.java create mode 100644 src/main/java/starlight/domain/backoffice/mail/BackofficeMailTemplate.java diff --git a/config b/config index f99b539..5acba1e 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit f99b5394463d91b5c31a7907dd48c81e1760ee61 +Subproject commit 5acba1ecc85733f96efaa9c60db314292dca268f diff --git a/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java b/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java new file mode 100644 index 0000000..47e3117 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java @@ -0,0 +1,46 @@ +package starlight.adapter.backoffice.mail.email; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; +import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendInput; +import starlight.application.backoffice.mail.required.MailSenderPort; +import starlight.domain.backoffice.mail.BackofficeMailContentType; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SmtpMailSender implements MailSenderPort { + + private final JavaMailSender javaMailSender; + + @Value("${spring.mail.username}") + private String senderEmail; + + @Override + public void send(BackofficeMailSendInput input, BackofficeMailContentType contentType) { + try { + MimeMessage message = javaMailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + + helper.setFrom(senderEmail); + helper.setTo(input.to().toArray(new String[0])); + helper.setSubject(input.subject()); + + boolean isHtml = contentType == BackofficeMailContentType.HTML; + String body = isHtml ? input.html() : input.text(); + helper.setText(body, isHtml); + + javaMailSender.send(message); + log.info("[MAIL] sent to={} subject={}", input.to(), input.subject()); + } catch (MessagingException e) { + log.error("[MAIL] send failed to={}", input.to(), e); + throw new IllegalArgumentException("메일 전송 실패"); + } + } +} diff --git a/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailSendLogJpa.java b/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailSendLogJpa.java new file mode 100644 index 0000000..600e70e --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailSendLogJpa.java @@ -0,0 +1,18 @@ +package starlight.adapter.backoffice.mail.persistence; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import starlight.application.backoffice.mail.required.BackofficeMailSendLogCommandPort; +import starlight.domain.backoffice.mail.BackofficeMailSendLog; + +@Repository +@RequiredArgsConstructor +public class BackofficeMailSendLogJpa implements BackofficeMailSendLogCommandPort { + + private final BackofficeMailSendLogRepository repository; + + @Override + public BackofficeMailSendLog save(BackofficeMailSendLog log) { + return repository.save(log); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailSendLogRepository.java b/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailSendLogRepository.java new file mode 100644 index 0000000..b8b7dab --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailSendLogRepository.java @@ -0,0 +1,7 @@ +package starlight.adapter.backoffice.mail.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; +import starlight.domain.backoffice.mail.BackofficeMailSendLog; + +public interface BackofficeMailSendLogRepository extends JpaRepository { +} diff --git a/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailTemplateJpa.java b/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailTemplateJpa.java new file mode 100644 index 0000000..63e4b42 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailTemplateJpa.java @@ -0,0 +1,31 @@ +package starlight.adapter.backoffice.mail.persistence; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import starlight.application.backoffice.mail.required.BackofficeMailTemplateCommandPort; +import starlight.application.backoffice.mail.required.BackofficeMailTemplateQueryPort; +import starlight.domain.backoffice.mail.BackofficeMailTemplate; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class BackofficeMailTemplateJpa implements BackofficeMailTemplateCommandPort, BackofficeMailTemplateQueryPort { + + private final BackofficeMailTemplateRepository repository; + + @Override + public BackofficeMailTemplate save(BackofficeMailTemplate template) { + return repository.save(template); + } + + @Override + public void deleteById(Long templateId) { + repository.deleteById(templateId); + } + + @Override + public List findAll() { + return repository.findAll(); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailTemplateRepository.java b/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailTemplateRepository.java new file mode 100644 index 0000000..0ecd52a --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailTemplateRepository.java @@ -0,0 +1,7 @@ +package starlight.adapter.backoffice.mail.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; +import starlight.domain.backoffice.mail.BackofficeMailTemplate; + +public interface BackofficeMailTemplateRepository extends JpaRepository { +} diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java new file mode 100644 index 0000000..a4d24f8 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java @@ -0,0 +1,52 @@ +package starlight.adapter.backoffice.mail.webapi; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; +import starlight.adapter.backoffice.mail.webapi.dto.request.BackofficeMailSendRequest; +import starlight.adapter.backoffice.mail.webapi.dto.response.BackofficeMailSendLogResponse; +import starlight.adapter.backoffice.mail.webapi.dto.request.BackofficeMailTemplateCreateRequest; +import starlight.adapter.backoffice.mail.webapi.dto.response.BackofficeMailTemplateResponse; +import starlight.application.backoffice.mail.provided.BackofficeMailSendUseCase; +import starlight.application.backoffice.mail.provided.BackofficeMailTemplateUseCase; +import starlight.shared.apiPayload.response.ApiResponse; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +public class BackofficeMailController { + + private final BackofficeMailSendUseCase backofficeMailSendUseCase; + private final BackofficeMailTemplateUseCase templateUseCase; + + @PostMapping("/v1/backoffice/mail/send") + public ApiResponse send(@Valid @RequestBody BackofficeMailSendRequest request) { + return ApiResponse.success(BackofficeMailSendLogResponse.from( + backofficeMailSendUseCase.send(request.toInput()) + )); + } + + @PostMapping("/v1/backoffice/mail/templates") + public ApiResponse createTemplate( + @Valid @RequestBody BackofficeMailTemplateCreateRequest request + ) { + return ApiResponse.success(BackofficeMailTemplateResponse.from( + templateUseCase.createTemplate(request.toInput()) + )); + } + + @GetMapping("/v1/backoffice/mail/templates") + public ApiResponse> findTemplates() { + return ApiResponse.success(templateUseCase.findTemplates().stream() + .map(BackofficeMailTemplateResponse::from) + .toList()); + } + + @DeleteMapping("/v1/backoffice/mail/templates/{templateId}") + public ApiResponse deleteTemplate(@PathVariable Long templateId) { + templateUseCase.deleteTemplate(templateId); + return ApiResponse.success(null); + } + +} diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailSendRequest.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailSendRequest.java new file mode 100644 index 0000000..a92a2f2 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailSendRequest.java @@ -0,0 +1,34 @@ +package starlight.adapter.backoffice.mail.webapi.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendInput; +import starlight.domain.backoffice.mail.BackofficeMailContentType; + +import java.util.List; + +public record BackofficeMailSendRequest( + @NotEmpty(message = "to is required") + List<@Email @NotBlank String> to, + @NotBlank(message = "subject is required") + String subject, + @NotBlank(message = "contentType is required") + String contentType, + String html, + String text +) { + public BackofficeMailSendInput toInput() { + return new BackofficeMailSendInput( + to, + subject, + contentType, + html, + text + ); + } + + public BackofficeMailContentType toContentType() { + return BackofficeMailContentType.from(contentType); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailTemplateCreateRequest.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailTemplateCreateRequest.java new file mode 100644 index 0000000..23d4344 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailTemplateCreateRequest.java @@ -0,0 +1,26 @@ +package starlight.adapter.backoffice.mail.webapi.dto.request; + +import jakarta.validation.constraints.NotBlank; +import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailTemplateCreateInput; +import starlight.domain.backoffice.mail.BackofficeMailContentType; + +public record BackofficeMailTemplateCreateRequest( + @NotBlank(message = "name is required") + String name, + @NotBlank(message = "title is required") + String title, + @NotBlank(message = "contentType is required") + String contentType, + String html, + String text +) { + public BackofficeMailTemplateCreateInput toInput() { + return new BackofficeMailTemplateCreateInput( + name, + title, + BackofficeMailContentType.from(contentType), + html, + text + ); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/response/BackofficeMailSendLogResponse.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/response/BackofficeMailSendLogResponse.java new file mode 100644 index 0000000..26621f7 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/response/BackofficeMailSendLogResponse.java @@ -0,0 +1,23 @@ +package starlight.adapter.backoffice.mail.webapi.dto.response; + +import starlight.application.backoffice.mail.provided.dto.result.BackofficeMailSendLogResult; + +import java.time.LocalDateTime; + +public record BackofficeMailSendLogResponse( + String recipients, + String subject, + String contentType, + boolean success, + String errorMessage +) { + public static BackofficeMailSendLogResponse from(BackofficeMailSendLogResult result) { + return new BackofficeMailSendLogResponse( + result.recipients(), + result.subject(), + result.contentType(), + result.success(), + result.errorMessage() + ); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/response/BackofficeMailTemplateResponse.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/response/BackofficeMailTemplateResponse.java new file mode 100644 index 0000000..c8d9533 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/response/BackofficeMailTemplateResponse.java @@ -0,0 +1,27 @@ +package starlight.adapter.backoffice.mail.webapi.dto.response; + +import starlight.application.backoffice.mail.provided.dto.result.BackofficeMailTemplateResult; + +import java.time.LocalDateTime; + +public record BackofficeMailTemplateResponse( + Long id, + String name, + String title, + String contentType, + String html, + String text, + LocalDateTime createdAt +) { + public static BackofficeMailTemplateResponse from(BackofficeMailTemplateResult result) { + return new BackofficeMailTemplateResponse( + result.id(), + result.name(), + result.title(), + result.contentType(), + result.html(), + result.text(), + result.createdAt() + ); + } +} diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailLogService.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailLogService.java new file mode 100644 index 0000000..2565afc --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailLogService.java @@ -0,0 +1,55 @@ +package starlight.application.backoffice.mail; + +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataAccessException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import starlight.application.backoffice.mail.provided.BackofficeMailLogUseCase; +import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendLogCreateInput; +import starlight.application.backoffice.mail.provided.dto.result.BackofficeMailSendLogResult; +import starlight.application.backoffice.mail.required.BackofficeMailSendLogCommandPort; +import starlight.domain.backoffice.exception.BackofficeErrorType; +import starlight.domain.backoffice.exception.BackofficeException; +import starlight.domain.backoffice.mail.BackofficeMailSendLog; + +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class BackofficeMailLogService implements BackofficeMailLogUseCase { + + private final BackofficeMailSendLogCommandPort logCommandPort; + + @Override + @Transactional + public BackofficeMailSendLogResult createLog(BackofficeMailSendLogCreateInput input) { + String recipients = input.to().stream().collect(Collectors.joining(",")); + + BackofficeMailSendLog log = BackofficeMailSendLog.create( + recipients, + input.subject(), + input.contentType(), + input.success(), + input.errorMessage() + ); + + try { + BackofficeMailSendLog saved = logCommandPort.save(log); + return toResult(saved); + } catch (DataAccessException exception) { + throw new BackofficeException(BackofficeErrorType.MAIL_LOG_SAVE_FAILED); + } + } + + private BackofficeMailSendLogResult toResult(BackofficeMailSendLog log) { + return new BackofficeMailSendLogResult( + log.getId(), + log.getRecipients(), + log.getEmailTitle(), + log.getContentType().name().toLowerCase(), + log.isSuccess(), + log.getErrorMessage(), + log.getCreatedAt() + ); + } +} diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java new file mode 100644 index 0000000..4a432de --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java @@ -0,0 +1,87 @@ +package starlight.application.backoffice.mail; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import starlight.application.backoffice.mail.provided.BackofficeMailLogUseCase; +import starlight.application.backoffice.mail.provided.BackofficeMailSendUseCase; +import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendInput; +import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendLogCreateInput; +import starlight.application.backoffice.mail.provided.dto.result.BackofficeMailSendLogResult; +import starlight.application.backoffice.mail.required.MailSenderPort; +import starlight.domain.backoffice.exception.BackofficeErrorType; +import starlight.domain.backoffice.exception.BackofficeException; +import starlight.domain.backoffice.mail.BackofficeMailContentType; + +@Service +@RequiredArgsConstructor +public class BackofficeMailSendService implements BackofficeMailSendUseCase { + + private final MailSenderPort mailSenderPort; + private final BackofficeMailLogUseCase logUseCase; + + @Override + @Transactional + public BackofficeMailSendLogResult send(BackofficeMailSendInput input) { + BackofficeMailContentType contentType = parseContentType(input.contentType()); + + try { + validate(input, contentType); + mailSenderPort.send(input, contentType); + return logUseCase.createLog(new BackofficeMailSendLogCreateInput( + input.to(), + input.subject(), + contentType, + true, + null + )); + } catch (IllegalArgumentException exception) { + return failAndThrow(input, contentType, exception.getMessage(), BackofficeErrorType.INVALID_MAIL_REQUEST); + } catch (Exception exception) { + return failAndThrow(input, contentType, exception.getMessage(), BackofficeErrorType.MAIL_SEND_FAILED); + } + } + + private BackofficeMailContentType parseContentType(String contentType) { + try { + return BackofficeMailContentType.from(contentType); + } catch (IllegalArgumentException exception) { + throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_CONTENT_TYPE); + } + } + + private void validate(BackofficeMailSendInput input, BackofficeMailContentType contentType) { + if (input.to() == null || input.to().isEmpty()) { + throw new IllegalArgumentException("recipient is required"); + } + if (input.subject() == null || input.subject().isBlank()) { + throw new IllegalArgumentException("subject is required"); + } + if (contentType == BackofficeMailContentType.HTML) { + if (input.html() == null || input.html().isBlank()) { + throw new IllegalArgumentException("html body is required"); + } + } + if (contentType == BackofficeMailContentType.TEXT) { + if (input.text() == null || input.text().isBlank()) { + throw new IllegalArgumentException("text body is required"); + } + } + } + + private BackofficeMailSendLogResult failAndThrow( + BackofficeMailSendInput input, + BackofficeMailContentType contentType, + String errorMessage, + BackofficeErrorType errorType + ) { + logUseCase.createLog(new BackofficeMailSendLogCreateInput( + input.to(), + input.subject(), + contentType, + false, + errorMessage + )); + throw new BackofficeException(errorType); + } +} diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailTemplateService.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailTemplateService.java new file mode 100644 index 0000000..0bdf4f4 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailTemplateService.java @@ -0,0 +1,77 @@ +package starlight.application.backoffice.mail; + +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataAccessException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import starlight.application.backoffice.mail.provided.BackofficeMailTemplateUseCase; +import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailTemplateCreateInput; +import starlight.application.backoffice.mail.provided.dto.result.BackofficeMailTemplateResult; +import starlight.application.backoffice.mail.required.BackofficeMailTemplateCommandPort; +import starlight.application.backoffice.mail.required.BackofficeMailTemplateQueryPort; +import starlight.domain.backoffice.exception.BackofficeErrorType; +import starlight.domain.backoffice.exception.BackofficeException; +import starlight.domain.backoffice.mail.BackofficeMailTemplate; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BackofficeMailTemplateService implements BackofficeMailTemplateUseCase { + + private final BackofficeMailTemplateCommandPort templateCommandPort; + private final BackofficeMailTemplateQueryPort templateQueryPort; + + @Override + @Transactional + public BackofficeMailTemplateResult createTemplate(BackofficeMailTemplateCreateInput input) { + BackofficeMailTemplate template = BackofficeMailTemplate.create( + input.name(), + input.title(), + input.contentType(), + input.html(), + input.text() + ); + + try { + BackofficeMailTemplate saved = templateCommandPort.save(template); + return toResult(saved); + } catch (DataAccessException exception) { + throw new BackofficeException(BackofficeErrorType.MAIL_TEMPLATE_SAVE_FAILED); + } + } + + @Override + @Transactional(readOnly = true) + public List findTemplates() { + try { + return templateQueryPort.findAll().stream() + .map(this::toResult) + .toList(); + } catch (DataAccessException exception) { + throw new BackofficeException(BackofficeErrorType.MAIL_TEMPLATE_QUERY_FAILED); + } + } + + @Override + @Transactional + public void deleteTemplate(Long templateId) { + try { + templateCommandPort.deleteById(templateId); + } catch (DataAccessException exception) { + throw new BackofficeException(BackofficeErrorType.MAIL_TEMPLATE_DELETE_FAILED); + } + } + + private BackofficeMailTemplateResult toResult(BackofficeMailTemplate template) { + return new BackofficeMailTemplateResult( + template.getId(), + template.getName(), + template.getEmailTitle(), + template.getContentType().name().toLowerCase(), + template.getHtml(), + template.getText(), + template.getCreatedAt() + ); + } +} diff --git a/src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailLogUseCase.java b/src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailLogUseCase.java new file mode 100644 index 0000000..7cd84c7 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailLogUseCase.java @@ -0,0 +1,9 @@ +package starlight.application.backoffice.mail.provided; + +import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendLogCreateInput; +import starlight.application.backoffice.mail.provided.dto.result.BackofficeMailSendLogResult; + +public interface BackofficeMailLogUseCase { + + BackofficeMailSendLogResult createLog(BackofficeMailSendLogCreateInput input); +} diff --git a/src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailSendUseCase.java b/src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailSendUseCase.java new file mode 100644 index 0000000..0b5cebc --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailSendUseCase.java @@ -0,0 +1,9 @@ +package starlight.application.backoffice.mail.provided; + +import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendInput; +import starlight.application.backoffice.mail.provided.dto.result.BackofficeMailSendLogResult; + +public interface BackofficeMailSendUseCase { + + BackofficeMailSendLogResult send(BackofficeMailSendInput input); +} diff --git a/src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailTemplateUseCase.java b/src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailTemplateUseCase.java new file mode 100644 index 0000000..b79c089 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailTemplateUseCase.java @@ -0,0 +1,15 @@ +package starlight.application.backoffice.mail.provided; + +import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailTemplateCreateInput; +import starlight.application.backoffice.mail.provided.dto.result.BackofficeMailTemplateResult; + +import java.util.List; + +public interface BackofficeMailTemplateUseCase { + + BackofficeMailTemplateResult createTemplate(BackofficeMailTemplateCreateInput input); + + List findTemplates(); + + void deleteTemplate(Long templateId); +} diff --git a/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailSendInput.java b/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailSendInput.java new file mode 100644 index 0000000..4d0c59b --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailSendInput.java @@ -0,0 +1,12 @@ +package starlight.application.backoffice.mail.provided.dto.input; + +import java.util.List; + +public record BackofficeMailSendInput( + List to, + String subject, + String contentType, + String html, + String text +) { +} diff --git a/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailSendLogCreateInput.java b/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailSendLogCreateInput.java new file mode 100644 index 0000000..0327196 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailSendLogCreateInput.java @@ -0,0 +1,14 @@ +package starlight.application.backoffice.mail.provided.dto.input; + +import starlight.domain.backoffice.mail.BackofficeMailContentType; + +import java.util.List; + +public record BackofficeMailSendLogCreateInput( + List to, + String subject, + BackofficeMailContentType contentType, + boolean success, + String errorMessage +) { +} diff --git a/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailTemplateCreateInput.java b/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailTemplateCreateInput.java new file mode 100644 index 0000000..90f82ce --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailTemplateCreateInput.java @@ -0,0 +1,12 @@ +package starlight.application.backoffice.mail.provided.dto.input; + +import starlight.domain.backoffice.mail.BackofficeMailContentType; + +public record BackofficeMailTemplateCreateInput( + String name, + String title, + BackofficeMailContentType contentType, + String html, + String text +) { +} diff --git a/src/main/java/starlight/application/backoffice/mail/provided/dto/result/BackofficeMailSendLogResult.java b/src/main/java/starlight/application/backoffice/mail/provided/dto/result/BackofficeMailSendLogResult.java new file mode 100644 index 0000000..d9dce47 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/provided/dto/result/BackofficeMailSendLogResult.java @@ -0,0 +1,14 @@ +package starlight.application.backoffice.mail.provided.dto.result; + +import java.time.LocalDateTime; + +public record BackofficeMailSendLogResult( + Long id, + String recipients, + String subject, + String contentType, + boolean success, + String errorMessage, + LocalDateTime createdAt +) { +} diff --git a/src/main/java/starlight/application/backoffice/mail/provided/dto/result/BackofficeMailTemplateResult.java b/src/main/java/starlight/application/backoffice/mail/provided/dto/result/BackofficeMailTemplateResult.java new file mode 100644 index 0000000..183641c --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/provided/dto/result/BackofficeMailTemplateResult.java @@ -0,0 +1,14 @@ +package starlight.application.backoffice.mail.provided.dto.result; + +import java.time.LocalDateTime; + +public record BackofficeMailTemplateResult( + Long id, + String name, + String title, + String contentType, + String html, + String text, + LocalDateTime createdAt +) { +} diff --git a/src/main/java/starlight/application/backoffice/mail/required/BackofficeMailSendLogCommandPort.java b/src/main/java/starlight/application/backoffice/mail/required/BackofficeMailSendLogCommandPort.java new file mode 100644 index 0000000..7d0815e --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/required/BackofficeMailSendLogCommandPort.java @@ -0,0 +1,8 @@ +package starlight.application.backoffice.mail.required; + +import starlight.domain.backoffice.mail.BackofficeMailSendLog; + +public interface BackofficeMailSendLogCommandPort { + + BackofficeMailSendLog save(BackofficeMailSendLog log); +} diff --git a/src/main/java/starlight/application/backoffice/mail/required/BackofficeMailTemplateCommandPort.java b/src/main/java/starlight/application/backoffice/mail/required/BackofficeMailTemplateCommandPort.java new file mode 100644 index 0000000..ff1bd22 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/required/BackofficeMailTemplateCommandPort.java @@ -0,0 +1,10 @@ +package starlight.application.backoffice.mail.required; + +import starlight.domain.backoffice.mail.BackofficeMailTemplate; + +public interface BackofficeMailTemplateCommandPort { + + BackofficeMailTemplate save(BackofficeMailTemplate template); + + void deleteById(Long templateId); +} diff --git a/src/main/java/starlight/application/backoffice/mail/required/BackofficeMailTemplateQueryPort.java b/src/main/java/starlight/application/backoffice/mail/required/BackofficeMailTemplateQueryPort.java new file mode 100644 index 0000000..797933c --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/required/BackofficeMailTemplateQueryPort.java @@ -0,0 +1,10 @@ +package starlight.application.backoffice.mail.required; + +import starlight.domain.backoffice.mail.BackofficeMailTemplate; + +import java.util.List; + +public interface BackofficeMailTemplateQueryPort { + + List findAll(); +} diff --git a/src/main/java/starlight/application/backoffice/mail/required/MailSenderPort.java b/src/main/java/starlight/application/backoffice/mail/required/MailSenderPort.java new file mode 100644 index 0000000..8da3a10 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/required/MailSenderPort.java @@ -0,0 +1,9 @@ +package starlight.application.backoffice.mail.required; + +import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendInput; +import starlight.domain.backoffice.mail.BackofficeMailContentType; + +public interface MailSenderPort { + + void send(BackofficeMailSendInput input, BackofficeMailContentType contentType); +} diff --git a/src/main/java/starlight/bootstrap/SecurityConfig.java b/src/main/java/starlight/bootstrap/SecurityConfig.java index dd30857..ddce9d6 100644 --- a/src/main/java/starlight/bootstrap/SecurityConfig.java +++ b/src/main/java/starlight/bootstrap/SecurityConfig.java @@ -73,6 +73,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/", "/index.html", "/ops.html", "/payment.html", "/api/payment/**").permitAll() .requestMatchers("/v1/auth/**","/v1/user/**", "/v1/experts", "/v1/experts/*").permitAll() + .requestMatchers("/v1/backoffice/mail/**").permitAll() + .requestMatchers("/send-email").permitAll() .requestMatchers("/login/**", "/oauth2/**", "/login/oauth2/**", "/public/**").permitAll() .requestMatchers("/v3/api-docs/**", "/v1/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/swagger-resources/**").permitAll() diff --git a/src/main/java/starlight/domain/backoffice/exception/BackofficeErrorType.java b/src/main/java/starlight/domain/backoffice/exception/BackofficeErrorType.java new file mode 100644 index 0000000..a1d9f1f --- /dev/null +++ b/src/main/java/starlight/domain/backoffice/exception/BackofficeErrorType.java @@ -0,0 +1,24 @@ +package starlight.domain.backoffice.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import starlight.shared.apiPayload.exception.ErrorType; + +@Getter +@RequiredArgsConstructor +public enum BackofficeErrorType implements ErrorType { + + INVALID_MAIL_CONTENT_TYPE(HttpStatus.BAD_REQUEST, "유효하지 않은 contentType입니다."), + INVALID_MAIL_REQUEST(HttpStatus.BAD_REQUEST, "메일 발송 요청이 유효하지 않습니다."), + MAIL_SEND_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "메일 전송에 실패했습니다."), + MAIL_TEMPLATE_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "메일 템플릿 저장에 실패했습니다."), + MAIL_TEMPLATE_QUERY_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "메일 템플릿 조회에 실패했습니다."), + MAIL_TEMPLATE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "메일 템플릿 삭제에 실패했습니다."), + MAIL_LOG_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "메일 로그 저장에 실패했습니다."), + ; + + private final HttpStatus status; + + private final String message; +} diff --git a/src/main/java/starlight/domain/backoffice/exception/BackofficeException.java b/src/main/java/starlight/domain/backoffice/exception/BackofficeException.java new file mode 100644 index 0000000..19bf61b --- /dev/null +++ b/src/main/java/starlight/domain/backoffice/exception/BackofficeException.java @@ -0,0 +1,11 @@ +package starlight.domain.backoffice.exception; + +import starlight.shared.apiPayload.exception.ErrorType; +import starlight.shared.apiPayload.exception.GlobalException; + +public class BackofficeException extends GlobalException { + + public BackofficeException(ErrorType errorType) { + super(errorType); + } +} diff --git a/src/main/java/starlight/domain/backoffice/mail/BackofficeMailContentType.java b/src/main/java/starlight/domain/backoffice/mail/BackofficeMailContentType.java new file mode 100644 index 0000000..308c8bf --- /dev/null +++ b/src/main/java/starlight/domain/backoffice/mail/BackofficeMailContentType.java @@ -0,0 +1,26 @@ +package starlight.domain.backoffice.mail; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum BackofficeMailContentType { + HTML("html"), + TEXT("텍스트"); + + private final String description; + + public static BackofficeMailContentType from(String value) { + if (value == null) { + throw new IllegalArgumentException("contentType is required"); + } + if ("html".equalsIgnoreCase(value)) { + return HTML; + } + if ("text".equalsIgnoreCase(value)) { + return TEXT; + } + throw new IllegalArgumentException("invalid contentType"); + } +} \ No newline at end of file diff --git a/src/main/java/starlight/domain/backoffice/mail/BackofficeMailSendLog.java b/src/main/java/starlight/domain/backoffice/mail/BackofficeMailSendLog.java new file mode 100644 index 0000000..dc29114 --- /dev/null +++ b/src/main/java/starlight/domain/backoffice/mail/BackofficeMailSendLog.java @@ -0,0 +1,50 @@ +package starlight.domain.backoffice.mail; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.util.Assert; +import starlight.shared.AbstractEntity; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BackofficeMailSendLog extends AbstractEntity { + + @Column(nullable = false, columnDefinition = "TEXT") + private String recipients; + + @Column(nullable = false) + private String emailTitle; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private BackofficeMailContentType contentType; + + @Column(nullable = false) + private boolean success; + + @Column(columnDefinition = "TEXT") + private String errorMessage; + + public static BackofficeMailSendLog create( + String recipients, String emailTitle, BackofficeMailContentType contentType, boolean success, String errorMessage + ) { + Assert.hasText(recipients, "recipients must not be empty"); + Assert.hasText(emailTitle, "subject must not be empty"); + Assert.notNull(contentType, "contentType must not be null"); + + BackofficeMailSendLog log = new BackofficeMailSendLog(); + log.recipients = recipients; + log.emailTitle = emailTitle; + log.contentType = contentType; + log.success = success; + log.errorMessage = errorMessage; + + return log; + } +} diff --git a/src/main/java/starlight/domain/backoffice/mail/BackofficeMailTemplate.java b/src/main/java/starlight/domain/backoffice/mail/BackofficeMailTemplate.java new file mode 100644 index 0000000..feee431 --- /dev/null +++ b/src/main/java/starlight/domain/backoffice/mail/BackofficeMailTemplate.java @@ -0,0 +1,50 @@ +package starlight.domain.backoffice.mail; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.util.Assert; +import starlight.shared.AbstractEntity; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BackofficeMailTemplate extends AbstractEntity { + + @Column(nullable = false) + private String name; + + @Column(nullable = false) + private String emailTitle; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private BackofficeMailContentType contentType; + + @Column(columnDefinition = "TEXT") + private String html; + + @Column(columnDefinition = "TEXT") + private String text; + + public static BackofficeMailTemplate create( + String name, String emailTitle, BackofficeMailContentType contentType, String html, String text + ) { + Assert.hasText(name, "name must not be empty"); + Assert.hasText(emailTitle, "title must not be empty"); + Assert.notNull(contentType, "contentType must not be null"); + + BackofficeMailTemplate template = new BackofficeMailTemplate(); + template.name = name; + template.emailTitle = emailTitle; + template.contentType = contentType; + template.html = html; + template.text = text; + + return template; + } +} From 81409ec169e007145b2417983ff5c94b8977c674 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Fri, 16 Jan 2026 01:55:12 +0900 Subject: [PATCH 35/74] =?UTF-8?q?[SRLT-132]=20feat:=20=EB=B0=B1=EC=98=A4?= =?UTF-8?q?=ED=94=BC=EC=8A=A4=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=9D=84=20=EA=B5=AC=ED=98=84=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config | 2 +- .../starlight/bootstrap/SecurityConfig.java | 59 ++++++++++++++++++- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/config b/config index 5acba1e..bcc30ec 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 5acba1ecc85733f96efaa9c60db314292dca268f +Subproject commit bcc30ec0d11eb5663b114a3b6c73894d67abbc01 diff --git a/src/main/java/starlight/bootstrap/SecurityConfig.java b/src/main/java/starlight/bootstrap/SecurityConfig.java index ddce9d6..e758f29 100644 --- a/src/main/java/starlight/bootstrap/SecurityConfig.java +++ b/src/main/java/starlight/bootstrap/SecurityConfig.java @@ -8,6 +8,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; +import org.springframework.core.annotation.Order; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -15,6 +16,15 @@ import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler; @@ -40,6 +50,8 @@ public class SecurityConfig { @Value("${cors.origin.server}") String ServerBaseUrl; @Value("${cors.origin.client}") String clientBaseUrl; @Value("${cors.origin.develop}") String devBaseUrl; + @Value("${backoffice.auth.username}") String backofficeUsername; + @Value("${backoffice.auth.password}") String backofficePassword; private final JwtFilter jwtFilter; private final ExceptionFilter exceptionFilter; @@ -49,6 +61,30 @@ public class SecurityConfig { private final OAuth2SuccessHandler oAuth2SuccessHandler; @Bean + @Order(1) + public SecurityFilterChain backofficeFilterChain(HttpSecurity http) throws Exception { + CsrfTokenRequestAttributeHandler csrfTokenRequestHandler = new CsrfTokenRequestAttributeHandler(); + + http.securityMatcher("/v1/backoffice/mail/**", "/login", "/logout") + .cors(Customizer.withDefaults()) + .csrf((csrf) -> csrf + .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + .csrfTokenRequestHandler(csrfTokenRequestHandler) + ) + .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) + .authorizeHttpRequests((authorize) -> + authorize + .requestMatchers("/login", "/logout").permitAll() + .anyRequest().hasRole("BACKOFFICE") + ) + .formLogin(Customizer.withDefaults()) + .logout(Customizer.withDefaults()); + + return http.build(); + } + + @Bean + @Order(2) public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.cors(Customizer.withDefaults()) .csrf(AbstractHttpConfigurer::disable); @@ -73,8 +109,6 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/", "/index.html", "/ops.html", "/payment.html", "/api/payment/**").permitAll() .requestMatchers("/v1/auth/**","/v1/user/**", "/v1/experts", "/v1/experts/*").permitAll() - .requestMatchers("/v1/backoffice/mail/**").permitAll() - .requestMatchers("/send-email").permitAll() .requestMatchers("/login/**", "/oauth2/**", "/login/oauth2/**", "/public/**").permitAll() .requestMatchers("/v3/api-docs/**", "/v1/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/swagger-resources/**").permitAll() @@ -124,6 +158,27 @@ public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + @Bean + public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) { + UserDetails user = User.builder() + .username(backofficeUsername) + .password(passwordEncoder.encode(backofficePassword)) + .roles("BACKOFFICE") + .build(); + return new InMemoryUserDetailsManager(user); + } + + @Bean + public AuthenticationProvider authenticationProvider( + UserDetailsService userDetailsService, + PasswordEncoder passwordEncoder + ) { + DaoAuthenticationProvider provider = new DaoAuthenticationProvider(); + provider.setUserDetailsService(userDetailsService); + provider.setPasswordEncoder(passwordEncoder); + return provider; + } + @Bean public LogoutSuccessHandler logoutSuccessHandler() { return new HttpStatusReturningLogoutSuccessHandler(); From ef24a8cbd8282f270ffb045162fabbc4c834700a Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Fri, 16 Jan 2026 17:01:52 +0900 Subject: [PATCH 36/74] =?UTF-8?q?[SRLT-132]=20feat:=20event=EB=A5=BC=20?= =?UTF-8?q?=ED=86=B5=ED=95=B4=20=EB=A1=9C=EA=B7=B8=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=88=98=EC=88=9C=EC=9D=84=20=EB=A9=94=EC=9D=B8=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=97=90=EC=84=9C=20=EB=B6=84=EB=A6=AC=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config | 2 +- .../mail/webapi/BackofficeMailController.java | 22 ++++---- .../request/BackofficeMailSendRequest.java | 13 +---- .../BackofficeMailTemplateCreateRequest.java | 8 +-- .../BackofficeMailSendLogResponse.java | 23 -------- .../mail/BackofficeMailLogService.java | 55 ------------------- .../BackofficeMailSendLogEventHandler.java | 28 ++++++++++ .../mail/BackofficeMailSendService.java | 26 ++++----- .../BackofficeMailSendEvent.java} | 4 +- .../provided/BackofficeMailLogUseCase.java | 9 --- .../provided/BackofficeMailSendUseCase.java | 3 +- .../dto/input/BackofficeMailSendInput.java | 3 +- .../BackofficeMailTemplateCreateInput.java | 3 +- .../result/BackofficeMailSendLogResult.java | 14 ----- 14 files changed, 60 insertions(+), 153 deletions(-) delete mode 100644 src/main/java/starlight/adapter/backoffice/mail/webapi/dto/response/BackofficeMailSendLogResponse.java delete mode 100644 src/main/java/starlight/application/backoffice/mail/BackofficeMailLogService.java create mode 100644 src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java rename src/main/java/starlight/application/backoffice/mail/{provided/dto/input/BackofficeMailSendLogCreateInput.java => event/BackofficeMailSendEvent.java} (68%) delete mode 100644 src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailLogUseCase.java delete mode 100644 src/main/java/starlight/application/backoffice/mail/provided/dto/result/BackofficeMailSendLogResult.java diff --git a/config b/config index bcc30ec..711559b 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit bcc30ec0d11eb5663b114a3b6c73894d67abbc01 +Subproject commit 711559bd8b08983d93e3fb61ffbd3f0b475f639d diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java index a4d24f8..e4c267d 100644 --- a/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java @@ -4,7 +4,6 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import starlight.adapter.backoffice.mail.webapi.dto.request.BackofficeMailSendRequest; -import starlight.adapter.backoffice.mail.webapi.dto.response.BackofficeMailSendLogResponse; import starlight.adapter.backoffice.mail.webapi.dto.request.BackofficeMailTemplateCreateRequest; import starlight.adapter.backoffice.mail.webapi.dto.response.BackofficeMailTemplateResponse; import starlight.application.backoffice.mail.provided.BackofficeMailSendUseCase; @@ -21,19 +20,19 @@ public class BackofficeMailController { private final BackofficeMailTemplateUseCase templateUseCase; @PostMapping("/v1/backoffice/mail/send") - public ApiResponse send(@Valid @RequestBody BackofficeMailSendRequest request) { - return ApiResponse.success(BackofficeMailSendLogResponse.from( - backofficeMailSendUseCase.send(request.toInput()) - )); + public ApiResponse send( + @Valid @RequestBody BackofficeMailSendRequest request + ) { + backofficeMailSendUseCase.send(request.toInput()); + return ApiResponse.success("이메일 전송 성공"); } @PostMapping("/v1/backoffice/mail/templates") public ApiResponse createTemplate( @Valid @RequestBody BackofficeMailTemplateCreateRequest request ) { - return ApiResponse.success(BackofficeMailTemplateResponse.from( - templateUseCase.createTemplate(request.toInput()) - )); + BackofficeMailTemplateResponse response = BackofficeMailTemplateResponse.from(templateUseCase.createTemplate(request.toInput())); + return ApiResponse.success(response); } @GetMapping("/v1/backoffice/mail/templates") @@ -44,9 +43,10 @@ public ApiResponse> findTemplates() { } @DeleteMapping("/v1/backoffice/mail/templates/{templateId}") - public ApiResponse deleteTemplate(@PathVariable Long templateId) { + public ApiResponse deleteTemplate( + @PathVariable Long templateId + ) { templateUseCase.deleteTemplate(templateId); - return ApiResponse.success(null); + return ApiResponse.success("템플릿이 삭제되었습니다."); } - } diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailSendRequest.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailSendRequest.java index a92a2f2..514ddcc 100644 --- a/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailSendRequest.java +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailSendRequest.java @@ -4,7 +4,6 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendInput; -import starlight.domain.backoffice.mail.BackofficeMailContentType; import java.util.List; @@ -19,16 +18,6 @@ public record BackofficeMailSendRequest( String text ) { public BackofficeMailSendInput toInput() { - return new BackofficeMailSendInput( - to, - subject, - contentType, - html, - text - ); - } - - public BackofficeMailContentType toContentType() { - return BackofficeMailContentType.from(contentType); + return new BackofficeMailSendInput(to, subject, contentType, html, text); } } diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailTemplateCreateRequest.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailTemplateCreateRequest.java index 23d4344..a185647 100644 --- a/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailTemplateCreateRequest.java +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailTemplateCreateRequest.java @@ -15,12 +15,6 @@ public record BackofficeMailTemplateCreateRequest( String text ) { public BackofficeMailTemplateCreateInput toInput() { - return new BackofficeMailTemplateCreateInput( - name, - title, - BackofficeMailContentType.from(contentType), - html, - text - ); + return new BackofficeMailTemplateCreateInput(name, title, BackofficeMailContentType.from(contentType), html, text); } } diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/response/BackofficeMailSendLogResponse.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/response/BackofficeMailSendLogResponse.java deleted file mode 100644 index 26621f7..0000000 --- a/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/response/BackofficeMailSendLogResponse.java +++ /dev/null @@ -1,23 +0,0 @@ -package starlight.adapter.backoffice.mail.webapi.dto.response; - -import starlight.application.backoffice.mail.provided.dto.result.BackofficeMailSendLogResult; - -import java.time.LocalDateTime; - -public record BackofficeMailSendLogResponse( - String recipients, - String subject, - String contentType, - boolean success, - String errorMessage -) { - public static BackofficeMailSendLogResponse from(BackofficeMailSendLogResult result) { - return new BackofficeMailSendLogResponse( - result.recipients(), - result.subject(), - result.contentType(), - result.success(), - result.errorMessage() - ); - } -} diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailLogService.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailLogService.java deleted file mode 100644 index 2565afc..0000000 --- a/src/main/java/starlight/application/backoffice/mail/BackofficeMailLogService.java +++ /dev/null @@ -1,55 +0,0 @@ -package starlight.application.backoffice.mail; - -import lombok.RequiredArgsConstructor; -import org.springframework.dao.DataAccessException; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import starlight.application.backoffice.mail.provided.BackofficeMailLogUseCase; -import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendLogCreateInput; -import starlight.application.backoffice.mail.provided.dto.result.BackofficeMailSendLogResult; -import starlight.application.backoffice.mail.required.BackofficeMailSendLogCommandPort; -import starlight.domain.backoffice.exception.BackofficeErrorType; -import starlight.domain.backoffice.exception.BackofficeException; -import starlight.domain.backoffice.mail.BackofficeMailSendLog; - -import java.util.stream.Collectors; - -@Service -@RequiredArgsConstructor -public class BackofficeMailLogService implements BackofficeMailLogUseCase { - - private final BackofficeMailSendLogCommandPort logCommandPort; - - @Override - @Transactional - public BackofficeMailSendLogResult createLog(BackofficeMailSendLogCreateInput input) { - String recipients = input.to().stream().collect(Collectors.joining(",")); - - BackofficeMailSendLog log = BackofficeMailSendLog.create( - recipients, - input.subject(), - input.contentType(), - input.success(), - input.errorMessage() - ); - - try { - BackofficeMailSendLog saved = logCommandPort.save(log); - return toResult(saved); - } catch (DataAccessException exception) { - throw new BackofficeException(BackofficeErrorType.MAIL_LOG_SAVE_FAILED); - } - } - - private BackofficeMailSendLogResult toResult(BackofficeMailSendLog log) { - return new BackofficeMailSendLogResult( - log.getId(), - log.getRecipients(), - log.getEmailTitle(), - log.getContentType().name().toLowerCase(), - log.isSuccess(), - log.getErrorMessage(), - log.getCreatedAt() - ); - } -} diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java new file mode 100644 index 0000000..4192826 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java @@ -0,0 +1,28 @@ +package starlight.application.backoffice.mail; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import starlight.application.backoffice.mail.event.BackofficeMailSendEvent; +import starlight.application.backoffice.mail.required.BackofficeMailSendLogCommandPort; +import starlight.domain.backoffice.mail.BackofficeMailSendLog; + +@Component +@RequiredArgsConstructor +public class BackofficeMailSendLogEventHandler { + + private final BackofficeMailSendLogCommandPort logCommandPort; + + @EventListener + public void handle(BackofficeMailSendEvent event) { + String recipients = String.join(",", event.to()); + BackofficeMailSendLog log = BackofficeMailSendLog.create( + recipients, + event.subject(), + event.contentType(), + event.success(), + event.errorMessage() + ); + logCommandPort.save(log); + } +} diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java index 4a432de..565a10a 100644 --- a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java @@ -3,12 +3,11 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import starlight.application.backoffice.mail.provided.BackofficeMailLogUseCase; import starlight.application.backoffice.mail.provided.BackofficeMailSendUseCase; import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendInput; -import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendLogCreateInput; -import starlight.application.backoffice.mail.provided.dto.result.BackofficeMailSendLogResult; import starlight.application.backoffice.mail.required.MailSenderPort; +import starlight.application.backoffice.mail.event.BackofficeMailSendEvent; +import org.springframework.context.ApplicationEventPublisher; import starlight.domain.backoffice.exception.BackofficeErrorType; import starlight.domain.backoffice.exception.BackofficeException; import starlight.domain.backoffice.mail.BackofficeMailContentType; @@ -18,27 +17,30 @@ public class BackofficeMailSendService implements BackofficeMailSendUseCase { private final MailSenderPort mailSenderPort; - private final BackofficeMailLogUseCase logUseCase; + private final ApplicationEventPublisher eventPublisher; @Override @Transactional - public BackofficeMailSendLogResult send(BackofficeMailSendInput input) { + public void send(BackofficeMailSendInput input) { BackofficeMailContentType contentType = parseContentType(input.contentType()); try { validate(input, contentType); mailSenderPort.send(input, contentType); - return logUseCase.createLog(new BackofficeMailSendLogCreateInput( + eventPublisher.publishEvent(new BackofficeMailSendEvent( input.to(), input.subject(), contentType, true, null )); + return; } catch (IllegalArgumentException exception) { - return failAndThrow(input, contentType, exception.getMessage(), BackofficeErrorType.INVALID_MAIL_REQUEST); + publishFailureEvent(input, contentType, exception.getMessage()); + throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_REQUEST); } catch (Exception exception) { - return failAndThrow(input, contentType, exception.getMessage(), BackofficeErrorType.MAIL_SEND_FAILED); + publishFailureEvent(input, contentType, exception.getMessage()); + throw new BackofficeException(BackofficeErrorType.MAIL_SEND_FAILED); } } @@ -69,19 +71,17 @@ private void validate(BackofficeMailSendInput input, BackofficeMailContentType c } } - private BackofficeMailSendLogResult failAndThrow( + private void publishFailureEvent( BackofficeMailSendInput input, BackofficeMailContentType contentType, - String errorMessage, - BackofficeErrorType errorType + String errorMessage ) { - logUseCase.createLog(new BackofficeMailSendLogCreateInput( + eventPublisher.publishEvent(new BackofficeMailSendEvent( input.to(), input.subject(), contentType, false, errorMessage )); - throw new BackofficeException(errorType); } } diff --git a/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailSendLogCreateInput.java b/src/main/java/starlight/application/backoffice/mail/event/BackofficeMailSendEvent.java similarity index 68% rename from src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailSendLogCreateInput.java rename to src/main/java/starlight/application/backoffice/mail/event/BackofficeMailSendEvent.java index 0327196..1f47d37 100644 --- a/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailSendLogCreateInput.java +++ b/src/main/java/starlight/application/backoffice/mail/event/BackofficeMailSendEvent.java @@ -1,10 +1,10 @@ -package starlight.application.backoffice.mail.provided.dto.input; +package starlight.application.backoffice.mail.event; import starlight.domain.backoffice.mail.BackofficeMailContentType; import java.util.List; -public record BackofficeMailSendLogCreateInput( +public record BackofficeMailSendEvent( List to, String subject, BackofficeMailContentType contentType, diff --git a/src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailLogUseCase.java b/src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailLogUseCase.java deleted file mode 100644 index 7cd84c7..0000000 --- a/src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailLogUseCase.java +++ /dev/null @@ -1,9 +0,0 @@ -package starlight.application.backoffice.mail.provided; - -import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendLogCreateInput; -import starlight.application.backoffice.mail.provided.dto.result.BackofficeMailSendLogResult; - -public interface BackofficeMailLogUseCase { - - BackofficeMailSendLogResult createLog(BackofficeMailSendLogCreateInput input); -} diff --git a/src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailSendUseCase.java b/src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailSendUseCase.java index 0b5cebc..3782488 100644 --- a/src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailSendUseCase.java +++ b/src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailSendUseCase.java @@ -1,9 +1,8 @@ package starlight.application.backoffice.mail.provided; import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendInput; -import starlight.application.backoffice.mail.provided.dto.result.BackofficeMailSendLogResult; public interface BackofficeMailSendUseCase { - BackofficeMailSendLogResult send(BackofficeMailSendInput input); + void send(BackofficeMailSendInput input); } diff --git a/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailSendInput.java b/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailSendInput.java index 4d0c59b..2a4aae9 100644 --- a/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailSendInput.java +++ b/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailSendInput.java @@ -8,5 +8,4 @@ public record BackofficeMailSendInput( String contentType, String html, String text -) { -} +) { } diff --git a/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailTemplateCreateInput.java b/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailTemplateCreateInput.java index 90f82ce..c55b83d 100644 --- a/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailTemplateCreateInput.java +++ b/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailTemplateCreateInput.java @@ -8,5 +8,4 @@ public record BackofficeMailTemplateCreateInput( BackofficeMailContentType contentType, String html, String text -) { -} +) { } diff --git a/src/main/java/starlight/application/backoffice/mail/provided/dto/result/BackofficeMailSendLogResult.java b/src/main/java/starlight/application/backoffice/mail/provided/dto/result/BackofficeMailSendLogResult.java deleted file mode 100644 index d9dce47..0000000 --- a/src/main/java/starlight/application/backoffice/mail/provided/dto/result/BackofficeMailSendLogResult.java +++ /dev/null @@ -1,14 +0,0 @@ -package starlight.application.backoffice.mail.provided.dto.result; - -import java.time.LocalDateTime; - -public record BackofficeMailSendLogResult( - Long id, - String recipients, - String subject, - String contentType, - boolean success, - String errorMessage, - LocalDateTime createdAt -) { -} From e5b3956767961c67a2ea485de703e7551e1ee4dc Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Fri, 16 Jan 2026 17:26:51 +0900 Subject: [PATCH 37/74] =?UTF-8?q?[SRLT-132]=20Refactor:=20=EB=B0=B1?= =?UTF-8?q?=EC=98=A4=ED=94=BC=EC=8A=A4=20=EB=A9=94=EC=9D=BC=20DTO=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=EC=9D=84=20=EC=A0=95?= =?UTF-8?q?=EC=A0=81=20=ED=8C=A9=ED=86=A0=EB=A6=AC=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mail/webapi/BackofficeMailController.java | 2 +- .../request/BackofficeMailSendRequest.java | 2 +- .../BackofficeMailTemplateCreateRequest.java | 4 +--- .../mail/BackofficeMailSendService.java | 10 ++++++---- .../mail/BackofficeMailTemplateService.java | 14 ++++++++++++-- .../mail/event/BackofficeMailSendEvent.java | 9 +++++++++ .../dto/input/BackofficeMailSendInput.java | 12 +++++++++++- .../BackofficeMailTemplateCreateInput.java | 16 ++++++++++++---- .../result/BackofficeMailTemplateResult.java | 19 +++++++++++++++++++ 9 files changed, 72 insertions(+), 16 deletions(-) diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java index e4c267d..f649253 100644 --- a/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java @@ -24,7 +24,7 @@ public ApiResponse send( @Valid @RequestBody BackofficeMailSendRequest request ) { backofficeMailSendUseCase.send(request.toInput()); - return ApiResponse.success("이메일 전송 성공"); + return ApiResponse.success("이메일 전송에 성공하였습니다."); } @PostMapping("/v1/backoffice/mail/templates") diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailSendRequest.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailSendRequest.java index 514ddcc..24b1763 100644 --- a/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailSendRequest.java +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailSendRequest.java @@ -18,6 +18,6 @@ public record BackofficeMailSendRequest( String text ) { public BackofficeMailSendInput toInput() { - return new BackofficeMailSendInput(to, subject, contentType, html, text); + return BackofficeMailSendInput.of(to, subject, contentType, html, text); } } diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailTemplateCreateRequest.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailTemplateCreateRequest.java index a185647..81974a6 100644 --- a/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailTemplateCreateRequest.java +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailTemplateCreateRequest.java @@ -2,8 +2,6 @@ import jakarta.validation.constraints.NotBlank; import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailTemplateCreateInput; -import starlight.domain.backoffice.mail.BackofficeMailContentType; - public record BackofficeMailTemplateCreateRequest( @NotBlank(message = "name is required") String name, @@ -15,6 +13,6 @@ public record BackofficeMailTemplateCreateRequest( String text ) { public BackofficeMailTemplateCreateInput toInput() { - return new BackofficeMailTemplateCreateInput(name, title, BackofficeMailContentType.from(contentType), html, text); + return BackofficeMailTemplateCreateInput.of(name, title, contentType, html, text); } } diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java index 565a10a..0724378 100644 --- a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java @@ -11,6 +11,7 @@ import starlight.domain.backoffice.exception.BackofficeErrorType; import starlight.domain.backoffice.exception.BackofficeException; import starlight.domain.backoffice.mail.BackofficeMailContentType; +import starlight.domain.backoffice.mail.BackofficeMailSendLog; @Service @RequiredArgsConstructor @@ -27,14 +28,15 @@ public void send(BackofficeMailSendInput input) { try { validate(input, contentType); mailSenderPort.send(input, contentType); - eventPublisher.publishEvent(new BackofficeMailSendEvent( + BackofficeMailSendEvent log = BackofficeMailSendEvent.of( input.to(), input.subject(), contentType, true, null - )); - return; + ); + eventPublisher.publishEvent(log); + } catch (IllegalArgumentException exception) { publishFailureEvent(input, contentType, exception.getMessage()); throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_REQUEST); @@ -76,7 +78,7 @@ private void publishFailureEvent( BackofficeMailContentType contentType, String errorMessage ) { - eventPublisher.publishEvent(new BackofficeMailSendEvent( + eventPublisher.publishEvent(BackofficeMailSendEvent.of( input.to(), input.subject(), contentType, diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailTemplateService.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailTemplateService.java index 0bdf4f4..bdd12e9 100644 --- a/src/main/java/starlight/application/backoffice/mail/BackofficeMailTemplateService.java +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailTemplateService.java @@ -11,6 +11,7 @@ import starlight.application.backoffice.mail.required.BackofficeMailTemplateQueryPort; import starlight.domain.backoffice.exception.BackofficeErrorType; import starlight.domain.backoffice.exception.BackofficeException; +import starlight.domain.backoffice.mail.BackofficeMailContentType; import starlight.domain.backoffice.mail.BackofficeMailTemplate; import java.util.List; @@ -25,10 +26,11 @@ public class BackofficeMailTemplateService implements BackofficeMailTemplateUseC @Override @Transactional public BackofficeMailTemplateResult createTemplate(BackofficeMailTemplateCreateInput input) { + BackofficeMailContentType contentType = parseContentType(input.contentType()); BackofficeMailTemplate template = BackofficeMailTemplate.create( input.name(), input.title(), - input.contentType(), + contentType, input.html(), input.text() ); @@ -41,6 +43,14 @@ public BackofficeMailTemplateResult createTemplate(BackofficeMailTemplateCreateI } } + private BackofficeMailContentType parseContentType(String contentType) { + try { + return BackofficeMailContentType.from(contentType); + } catch (IllegalArgumentException exception) { + throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_CONTENT_TYPE); + } + } + @Override @Transactional(readOnly = true) public List findTemplates() { @@ -64,7 +74,7 @@ public void deleteTemplate(Long templateId) { } private BackofficeMailTemplateResult toResult(BackofficeMailTemplate template) { - return new BackofficeMailTemplateResult( + return BackofficeMailTemplateResult.of( template.getId(), template.getName(), template.getEmailTitle(), diff --git a/src/main/java/starlight/application/backoffice/mail/event/BackofficeMailSendEvent.java b/src/main/java/starlight/application/backoffice/mail/event/BackofficeMailSendEvent.java index 1f47d37..b13163b 100644 --- a/src/main/java/starlight/application/backoffice/mail/event/BackofficeMailSendEvent.java +++ b/src/main/java/starlight/application/backoffice/mail/event/BackofficeMailSendEvent.java @@ -11,4 +11,13 @@ public record BackofficeMailSendEvent( boolean success, String errorMessage ) { + public static BackofficeMailSendEvent of( + List to, + String subject, + BackofficeMailContentType contentType, + boolean success, + String errorMessage + ) { + return new BackofficeMailSendEvent(to, subject, contentType, success, errorMessage); + } } diff --git a/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailSendInput.java b/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailSendInput.java index 2a4aae9..dde0c20 100644 --- a/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailSendInput.java +++ b/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailSendInput.java @@ -8,4 +8,14 @@ public record BackofficeMailSendInput( String contentType, String html, String text -) { } +) { + public static BackofficeMailSendInput of( + List to, + String subject, + String contentType, + String html, + String text + ) { + return new BackofficeMailSendInput(to, subject, contentType, html, text); + } +} diff --git a/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailTemplateCreateInput.java b/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailTemplateCreateInput.java index c55b83d..83867cf 100644 --- a/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailTemplateCreateInput.java +++ b/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailTemplateCreateInput.java @@ -1,11 +1,19 @@ package starlight.application.backoffice.mail.provided.dto.input; -import starlight.domain.backoffice.mail.BackofficeMailContentType; - public record BackofficeMailTemplateCreateInput( String name, String title, - BackofficeMailContentType contentType, + String contentType, String html, String text -) { } +) { + public static BackofficeMailTemplateCreateInput of( + String name, + String title, + String contentType, + String html, + String text + ) { + return new BackofficeMailTemplateCreateInput(name, title, contentType, html, text); + } +} diff --git a/src/main/java/starlight/application/backoffice/mail/provided/dto/result/BackofficeMailTemplateResult.java b/src/main/java/starlight/application/backoffice/mail/provided/dto/result/BackofficeMailTemplateResult.java index 183641c..bbe225b 100644 --- a/src/main/java/starlight/application/backoffice/mail/provided/dto/result/BackofficeMailTemplateResult.java +++ b/src/main/java/starlight/application/backoffice/mail/provided/dto/result/BackofficeMailTemplateResult.java @@ -11,4 +11,23 @@ public record BackofficeMailTemplateResult( String text, LocalDateTime createdAt ) { + public static BackofficeMailTemplateResult of( + Long id, + String name, + String title, + String contentType, + String html, + String text, + LocalDateTime createdAt + ) { + return new BackofficeMailTemplateResult( + id, + name, + title, + contentType, + html, + text, + createdAt + ); + } } From 39f4e87a8e439b5b3b8835d993756b40f8305572 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Fri, 16 Jan 2026 17:30:06 +0900 Subject: [PATCH 38/74] =?UTF-8?q?[SRLT-132]=20Chore:=20=EB=B0=B1=EC=98=A4?= =?UTF-8?q?=ED=94=BC=EC=8A=A4=20=EC=A3=BC=EC=86=8C=EB=A5=BC=20cors=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=EC=97=90=20=EC=B6=94=EA=B0=80=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config | 2 +- src/main/java/starlight/bootstrap/SecurityConfig.java | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/config b/config index 711559b..5a95ffd 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 711559bd8b08983d93e3fb61ffbd3f0b475f639d +Subproject commit 5a95ffdd23c40f8409f39c70d256d5705d8ae856 diff --git a/src/main/java/starlight/bootstrap/SecurityConfig.java b/src/main/java/starlight/bootstrap/SecurityConfig.java index e758f29..734f6ee 100644 --- a/src/main/java/starlight/bootstrap/SecurityConfig.java +++ b/src/main/java/starlight/bootstrap/SecurityConfig.java @@ -49,6 +49,7 @@ public class SecurityConfig { @Value("${cors.origin.server}") String ServerBaseUrl; @Value("${cors.origin.client}") String clientBaseUrl; + @Value("${cors.origin.office}") String officeBaseUrl; @Value("${cors.origin.develop}") String devBaseUrl; @Value("${backoffice.auth.username}") String backofficeUsername; @Value("${backoffice.auth.password}") String backofficePassword; @@ -138,7 +139,8 @@ public CorsConfigurationSource corsConfigurationSource() { configuration.setAllowedOrigins(List.of( clientBaseUrl, ServerBaseUrl, - devBaseUrl + devBaseUrl, + officeBaseUrl )); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); From cb3023ed3607fff2b45e1fd5c2e0b2ef6892266a Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Fri, 16 Jan 2026 17:34:22 +0900 Subject: [PATCH 39/74] =?UTF-8?q?[SRLT-132]=20Chore:=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=EB=A5=BC=20?= =?UTF-8?q?=EC=B5=9C=EC=8B=A0=ED=99=94=ED=95=9C=EB=8B=A4?= 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 5a95ffd..1747606 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 5a95ffdd23c40f8409f39c70d256d5705d8ae856 +Subproject commit 17476065de9fec54bf5604f1c9e96932bda3c34c From 417fe2f1c6bb30d47c5d31b6ccbe5631b88745e3 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Sat, 17 Jan 2026 01:12:36 +0900 Subject: [PATCH 40/74] =?UTF-8?q?[SRLT-132]=20Fix:=20CSRF=20=EC=BF=A0?= =?UTF-8?q?=ED=82=A4=20SameSite/Secure=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/starlight/bootstrap/SecurityConfig.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/starlight/bootstrap/SecurityConfig.java b/src/main/java/starlight/bootstrap/SecurityConfig.java index 734f6ee..11633bb 100644 --- a/src/main/java/starlight/bootstrap/SecurityConfig.java +++ b/src/main/java/starlight/bootstrap/SecurityConfig.java @@ -65,11 +65,13 @@ public class SecurityConfig { @Order(1) public SecurityFilterChain backofficeFilterChain(HttpSecurity http) throws Exception { CsrfTokenRequestAttributeHandler csrfTokenRequestHandler = new CsrfTokenRequestAttributeHandler(); + CookieCsrfTokenRepository csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse(); + csrfTokenRepository.setCookieCustomizer(cookie -> cookie.sameSite("None").secure(true)); http.securityMatcher("/v1/backoffice/mail/**", "/login", "/logout") .cors(Customizer.withDefaults()) .csrf((csrf) -> csrf - .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) + .csrfTokenRepository(csrfTokenRepository) .csrfTokenRequestHandler(csrfTokenRequestHandler) ) .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) From 1c82af182090681cc5d9ea13b77eb8556e4696b3 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Sat, 17 Jan 2026 12:00:32 +0900 Subject: [PATCH 41/74] =?UTF-8?q?[SRLT-132]=20Fix:=20CSRF=20=EC=BF=A0?= =?UTF-8?q?=ED=82=A4=20=EB=8F=84=EB=A9=94=EC=9D=B8/SameSite/Secure=20?= =?UTF-8?q?=EC=84=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/starlight/bootstrap/SecurityConfig.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/starlight/bootstrap/SecurityConfig.java b/src/main/java/starlight/bootstrap/SecurityConfig.java index 11633bb..65f5e77 100644 --- a/src/main/java/starlight/bootstrap/SecurityConfig.java +++ b/src/main/java/starlight/bootstrap/SecurityConfig.java @@ -66,7 +66,11 @@ public class SecurityConfig { public SecurityFilterChain backofficeFilterChain(HttpSecurity http) throws Exception { CsrfTokenRequestAttributeHandler csrfTokenRequestHandler = new CsrfTokenRequestAttributeHandler(); CookieCsrfTokenRepository csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse(); - csrfTokenRepository.setCookieCustomizer(cookie -> cookie.sameSite("None").secure(true)); + csrfTokenRepository.setCookieCustomizer(cookie -> cookie + .domain(".starlight-official.co.kr") + .sameSite("None") + .secure(true) + ); http.securityMatcher("/v1/backoffice/mail/**", "/login", "/logout") .cors(Customizer.withDefaults()) From 1d5973dce4aa75d08a3cafc4fddf0ac14ead0541 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Sat, 17 Jan 2026 13:47:49 +0900 Subject: [PATCH 42/74] =?UTF-8?q?[SRLT-132]=20Fix:=20.=20=EB=B9=BC?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/starlight/bootstrap/SecurityConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/starlight/bootstrap/SecurityConfig.java b/src/main/java/starlight/bootstrap/SecurityConfig.java index 65f5e77..e5c004f 100644 --- a/src/main/java/starlight/bootstrap/SecurityConfig.java +++ b/src/main/java/starlight/bootstrap/SecurityConfig.java @@ -67,7 +67,7 @@ public SecurityFilterChain backofficeFilterChain(HttpSecurity http) throws Excep CsrfTokenRequestAttributeHandler csrfTokenRequestHandler = new CsrfTokenRequestAttributeHandler(); CookieCsrfTokenRepository csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse(); csrfTokenRepository.setCookieCustomizer(cookie -> cookie - .domain(".starlight-official.co.kr") + .domain("starlight-official.co.kr") .sameSite("None") .secure(true) ); From 824890f839d35aa44dafd1d8f9bc093db5a57563 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Sat, 17 Jan 2026 14:26:05 +0900 Subject: [PATCH 43/74] =?UTF-8?q?[SRLT-132]=20Fix:=20CSRF=20=EC=BF=A0?= =?UTF-8?q?=ED=82=A4=20=EB=8F=84=EB=A9=94=EC=9D=B8/SameSite/Secure=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= 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 1747606..3d7570e 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 17476065de9fec54bf5604f1c9e96932bda3c34c +Subproject commit 3d7570eb05dcac08e5663f31307ec34551d32bff From 9301af145fec4d26d70c9380a1625a79d248463f Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Sat, 17 Jan 2026 14:50:01 +0900 Subject: [PATCH 44/74] =?UTF-8?q?[SRLT-132]=20Fix:=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EB=B3=80=EC=88=98=20remoteip=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= 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 3d7570e..e3521f9 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 3d7570eb05dcac08e5663f31307ec34551d32bff +Subproject commit e3521f9022064bbcddf6c10d27ade6fc40651709 From fe0e2c39589631ddf0e468492a3d2901c1cf3ce7 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Sat, 17 Jan 2026 15:05:34 +0900 Subject: [PATCH 45/74] =?UTF-8?q?[SRLT-132]=20Fix:=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EB=B3=80=EC=88=98=20remoteip=20=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= 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 e3521f9..a9d98d9 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit e3521f9022064bbcddf6c10d27ade6fc40651709 +Subproject commit a9d98d9b2ad05b4d753fd0abeb264508317418c3 From 89ac496c9cb37aced78ecddb9cf0d96d001d18cc Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Sat, 17 Jan 2026 15:33:17 +0900 Subject: [PATCH 46/74] =?UTF-8?q?[SRLT-132]=20Fix:=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EB=B3=84=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/starlight/bootstrap/SecurityConfig.java | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/main/java/starlight/bootstrap/SecurityConfig.java b/src/main/java/starlight/bootstrap/SecurityConfig.java index e5c004f..97ed9a4 100644 --- a/src/main/java/starlight/bootstrap/SecurityConfig.java +++ b/src/main/java/starlight/bootstrap/SecurityConfig.java @@ -9,6 +9,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.core.annotation.Order; +import org.springframework.core.env.Environment; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -54,6 +55,7 @@ public class SecurityConfig { @Value("${backoffice.auth.username}") String backofficeUsername; @Value("${backoffice.auth.password}") String backofficePassword; + private final Environment environment; private final JwtFilter jwtFilter; private final ExceptionFilter exceptionFilter; private final JwtAccessDeniedHandler jwtAccessDeniedHandler; @@ -66,17 +68,21 @@ public class SecurityConfig { public SecurityFilterChain backofficeFilterChain(HttpSecurity http) throws Exception { CsrfTokenRequestAttributeHandler csrfTokenRequestHandler = new CsrfTokenRequestAttributeHandler(); CookieCsrfTokenRepository csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse(); - csrfTokenRepository.setCookieCustomizer(cookie -> cookie - .domain("starlight-official.co.kr") - .sameSite("None") - .secure(true) - ); + boolean isDevProfile = List.of(environment.getActiveProfiles()).contains("dev"); + if (!isDevProfile) { + csrfTokenRepository.setCookieCustomizer(cookie -> cookie + .domain("starlight-official.co.kr") + .sameSite("None") + .secure(true) + ); + } http.securityMatcher("/v1/backoffice/mail/**", "/login", "/logout") .cors(Customizer.withDefaults()) .csrf((csrf) -> csrf .csrfTokenRepository(csrfTokenRepository) .csrfTokenRequestHandler(csrfTokenRequestHandler) + .ignoringRequestMatchers("/login", "/logout") ) .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) .authorizeHttpRequests((authorize) -> From f45cb620cd410c830fd2effe3320f08108b8bb86 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Sat, 17 Jan 2026 15:52:50 +0900 Subject: [PATCH 47/74] =?UTF-8?q?[SRLT-132]=20Fix:=20=EB=B0=B1=EC=98=A4?= =?UTF-8?q?=ED=94=BC=EC=8A=A4=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=A6=AC?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EB=A0=89=ED=8A=B8=20HTTPS=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=20-=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=84=B1?= =?UTF-8?q?=EA=B3=B5=20=EC=8B=9C=20X-Forwarded-*=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20HTTPS=20redirect=20-=20mixed-content=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=20=EB=AC=B8=EC=A0=9C=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../starlight/bootstrap/SecurityConfig.java | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/main/java/starlight/bootstrap/SecurityConfig.java b/src/main/java/starlight/bootstrap/SecurityConfig.java index 97ed9a4..e47c53d 100644 --- a/src/main/java/starlight/bootstrap/SecurityConfig.java +++ b/src/main/java/starlight/bootstrap/SecurityConfig.java @@ -90,7 +90,22 @@ public SecurityFilterChain backofficeFilterChain(HttpSecurity http) throws Excep .requestMatchers("/login", "/logout").permitAll() .anyRequest().hasRole("BACKOFFICE") ) - .formLogin(Customizer.withDefaults()) + .formLogin((form) -> form.successHandler((request, response, authentication) -> { + String proto = request.getHeader("X-Forwarded-Proto"); + if (proto == null || proto.isBlank()) { + proto = request.getScheme(); + } + String host = request.getHeader("X-Forwarded-Host"); + if (host == null || host.isBlank()) { + host = request.getServerName(); + } + String port = request.getHeader("X-Forwarded-Port"); + String portPart = ""; + if (port != null && !port.isBlank() && !"443".equals(port) && !"80".equals(port)) { + portPart = ":" + port; + } + response.sendRedirect(proto + "://" + host + portPart + "/"); + })) .logout(Customizer.withDefaults()); return http.build(); From 73f59b0b3c33cb3d165a5f52bdfb2b6f3d0940ff Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Sat, 17 Jan 2026 16:23:38 +0900 Subject: [PATCH 48/74] =?UTF-8?q?[SRLT-132]=20Fix:=20=EB=B0=B1=EC=98=A4?= =?UTF-8?q?=ED=94=BC=EC=8A=A4=EC=AA=BD=EC=9C=BC=EB=A1=9C=20Redirect=20?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../starlight/bootstrap/SecurityConfig.java | 22 +++++-------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/src/main/java/starlight/bootstrap/SecurityConfig.java b/src/main/java/starlight/bootstrap/SecurityConfig.java index e47c53d..6744acd 100644 --- a/src/main/java/starlight/bootstrap/SecurityConfig.java +++ b/src/main/java/starlight/bootstrap/SecurityConfig.java @@ -77,6 +77,9 @@ public SecurityFilterChain backofficeFilterChain(HttpSecurity http) throws Excep ); } + String officeRedirectUrl = officeBaseUrl.endsWith("/") + ? officeBaseUrl + : officeBaseUrl + "/"; http.securityMatcher("/v1/backoffice/mail/**", "/login", "/logout") .cors(Customizer.withDefaults()) .csrf((csrf) -> csrf @@ -90,22 +93,9 @@ public SecurityFilterChain backofficeFilterChain(HttpSecurity http) throws Excep .requestMatchers("/login", "/logout").permitAll() .anyRequest().hasRole("BACKOFFICE") ) - .formLogin((form) -> form.successHandler((request, response, authentication) -> { - String proto = request.getHeader("X-Forwarded-Proto"); - if (proto == null || proto.isBlank()) { - proto = request.getScheme(); - } - String host = request.getHeader("X-Forwarded-Host"); - if (host == null || host.isBlank()) { - host = request.getServerName(); - } - String port = request.getHeader("X-Forwarded-Port"); - String portPart = ""; - if (port != null && !port.isBlank() && !"443".equals(port) && !"80".equals(port)) { - portPart = ":" + port; - } - response.sendRedirect(proto + "://" + host + portPart + "/"); - })) + .formLogin((form) -> form.successHandler( + (request, response, authentication) -> response.sendRedirect(officeRedirectUrl) + )) .logout(Customizer.withDefaults()); return http.build(); From 1677d6c8a01c697ea54e8b06e646fbc15c0efae3 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Mon, 19 Jan 2026 10:44:00 +0900 Subject: [PATCH 49/74] =?UTF-8?q?[SRLT-132]=20Refactor:=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EB=9E=98=EB=B9=97=20=EB=A6=AC=EB=B7=B0=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config | 2 +- .../backoffice/mail/email/SmtpMailSender.java | 4 +++- .../request/BackofficeMailSendRequest.java | 16 ++++++++++++++++ .../starlight/bootstrap/SecurityConfig.java | 19 ++++++++----------- 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/config b/config index a9d98d9..d699182 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit a9d98d9b2ad05b4d753fd0abeb264508317418c3 +Subproject commit d69918214bb7bdb96031e7c45d3cbdef1aec1834 diff --git a/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java b/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java index 47e3117..5f635a2 100644 --- a/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java +++ b/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java @@ -10,6 +10,8 @@ import org.springframework.stereotype.Service; import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendInput; import starlight.application.backoffice.mail.required.MailSenderPort; +import starlight.domain.backoffice.exception.BackofficeErrorType; +import starlight.domain.backoffice.exception.BackofficeException; import starlight.domain.backoffice.mail.BackofficeMailContentType; @Slf4j @@ -40,7 +42,7 @@ public void send(BackofficeMailSendInput input, BackofficeMailContentType conten log.info("[MAIL] sent to={} subject={}", input.to(), input.subject()); } catch (MessagingException e) { log.error("[MAIL] send failed to={}", input.to(), e); - throw new IllegalArgumentException("메일 전송 실패"); + throw new BackofficeException(BackofficeErrorType.MAIL_SEND_FAILED); } } } diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailSendRequest.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailSendRequest.java index 24b1763..fe6dff0 100644 --- a/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailSendRequest.java +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailSendRequest.java @@ -1,8 +1,10 @@ package starlight.adapter.backoffice.mail.webapi.dto.request; import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; +import org.springframework.util.StringUtils; import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendInput; import java.util.List; @@ -17,6 +19,20 @@ public record BackofficeMailSendRequest( String html, String text ) { + @AssertTrue(message = "html is required for html contentType; text is required for text contentType") + public boolean isBodyProvided() { + if (!StringUtils.hasText(contentType)) { + return true; + } + if ("html".equalsIgnoreCase(contentType)) { + return StringUtils.hasText(html); + } + if ("text".equalsIgnoreCase(contentType)) { + return StringUtils.hasText(text); + } + return true; + } + public BackofficeMailSendInput toInput() { return BackofficeMailSendInput.of(to, subject, contentType, html, text); } diff --git a/src/main/java/starlight/bootstrap/SecurityConfig.java b/src/main/java/starlight/bootstrap/SecurityConfig.java index 6744acd..07f4232 100644 --- a/src/main/java/starlight/bootstrap/SecurityConfig.java +++ b/src/main/java/starlight/bootstrap/SecurityConfig.java @@ -25,7 +25,6 @@ import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler; -import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler; @@ -53,7 +52,7 @@ public class SecurityConfig { @Value("${cors.origin.office}") String officeBaseUrl; @Value("${cors.origin.develop}") String devBaseUrl; @Value("${backoffice.auth.username}") String backofficeUsername; - @Value("${backoffice.auth.password}") String backofficePassword; + @Value("${backoffice.auth.password-hash}") String backofficePasswordHash; private final Environment environment; private final JwtFilter jwtFilter; @@ -77,15 +76,11 @@ public SecurityFilterChain backofficeFilterChain(HttpSecurity http) throws Excep ); } - String officeRedirectUrl = officeBaseUrl.endsWith("/") - ? officeBaseUrl - : officeBaseUrl + "/"; http.securityMatcher("/v1/backoffice/mail/**", "/login", "/logout") .cors(Customizer.withDefaults()) .csrf((csrf) -> csrf .csrfTokenRepository(csrfTokenRepository) .csrfTokenRequestHandler(csrfTokenRequestHandler) - .ignoringRequestMatchers("/login", "/logout") ) .sessionManagement((session) -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) .authorizeHttpRequests((authorize) -> @@ -93,9 +88,7 @@ public SecurityFilterChain backofficeFilterChain(HttpSecurity http) throws Excep .requestMatchers("/login", "/logout").permitAll() .anyRequest().hasRole("BACKOFFICE") ) - .formLogin((form) -> form.successHandler( - (request, response, authentication) -> response.sendRedirect(officeRedirectUrl) - )) + .formLogin(Customizer.withDefaults()) .logout(Customizer.withDefaults()); return http.build(); @@ -178,10 +171,13 @@ public PasswordEncoder passwordEncoder() { } @Bean - public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) { + public UserDetailsService userDetailsService() { + if (backofficePasswordHash == null || backofficePasswordHash.isBlank()) { + throw new IllegalStateException("backoffice.auth.password-hash must be configured"); + } UserDetails user = User.builder() .username(backofficeUsername) - .password(passwordEncoder.encode(backofficePassword)) + .password(backofficePasswordHash) .roles("BACKOFFICE") .build(); return new InMemoryUserDetailsManager(user); @@ -202,4 +198,5 @@ public AuthenticationProvider authenticationProvider( public LogoutSuccessHandler logoutSuccessHandler() { return new HttpStatusReturningLogoutSuccessHandler(); } + } From b70d0a12cfb5b85a67f53c94b780d69e9ae30ca6 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Mon, 19 Jan 2026 11:44:39 +0900 Subject: [PATCH 50/74] =?UTF-8?q?[SRLT-132]=20Refactor:=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EB=9E=98=EB=B9=97=20=EB=A6=AC=EB=B7=B0=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/backoffice/mail/email/SmtpMailSender.java | 2 +- .../mail/webapi/dto/request/BackofficeMailSendRequest.java | 2 ++ .../domain/backoffice/exception/BackofficeException.java | 4 ++++ .../shared/apiPayload/exception/GlobalException.java | 7 ++++++- 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java b/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java index 5f635a2..c5c3d6f 100644 --- a/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java +++ b/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java @@ -42,7 +42,7 @@ public void send(BackofficeMailSendInput input, BackofficeMailContentType conten log.info("[MAIL] sent to={} subject={}", input.to(), input.subject()); } catch (MessagingException e) { log.error("[MAIL] send failed to={}", input.to(), e); - throw new BackofficeException(BackofficeErrorType.MAIL_SEND_FAILED); + throw new BackofficeException(BackofficeErrorType.MAIL_SEND_FAILED, e); } } } diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailSendRequest.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailSendRequest.java index fe6dff0..616df1c 100644 --- a/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailSendRequest.java +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailSendRequest.java @@ -4,6 +4,7 @@ import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; import org.springframework.util.StringUtils; import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendInput; @@ -15,6 +16,7 @@ public record BackofficeMailSendRequest( @NotBlank(message = "subject is required") String subject, @NotBlank(message = "contentType is required") + @Pattern(regexp = "(?i)^(html|text)$", message = "contentType must be html or text") String contentType, String html, String text diff --git a/src/main/java/starlight/domain/backoffice/exception/BackofficeException.java b/src/main/java/starlight/domain/backoffice/exception/BackofficeException.java index 19bf61b..008b2d8 100644 --- a/src/main/java/starlight/domain/backoffice/exception/BackofficeException.java +++ b/src/main/java/starlight/domain/backoffice/exception/BackofficeException.java @@ -8,4 +8,8 @@ public class BackofficeException extends GlobalException { public BackofficeException(ErrorType errorType) { super(errorType); } + + public BackofficeException(ErrorType errorType, Throwable cause) { + super(errorType, cause); + } } diff --git a/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java b/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java index 1c7a819..7ecbd40 100644 --- a/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java +++ b/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java @@ -11,4 +11,9 @@ public GlobalException(ErrorType errorType) { super(errorType.getMessage()); this.errorType = errorType; } -} \ No newline at end of file + + public GlobalException(ErrorType errorType, Throwable cause) { + super(errorType.getMessage(), cause); + this.errorType = errorType; + } +} From 5d72f36f6cadfcc0222d4fad321ad8d933e574b7 Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Sat, 24 Jan 2026 20:36:40 -0600 Subject: [PATCH 51/74] =?UTF-8?q?[SRLT-124]=20Chore:=20MemberLookUpPort=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=EB=B0=8F=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EB=A9=94=EC=86=8C=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/member/persistence/MemberJpa.java | 10 ++-------- .../application/businessplan/BusinessPlanService.java | 6 +++--- .../businessplan/required/MemberLookUpPort.java | 7 +++++++ .../application/member/required/MemberQueryPort.java | 2 -- 4 files changed, 12 insertions(+), 13 deletions(-) create mode 100644 src/main/java/starlight/application/businessplan/required/MemberLookUpPort.java diff --git a/src/main/java/starlight/adapter/member/persistence/MemberJpa.java b/src/main/java/starlight/adapter/member/persistence/MemberJpa.java index 79e6b1a..76ec363 100644 --- a/src/main/java/starlight/adapter/member/persistence/MemberJpa.java +++ b/src/main/java/starlight/adapter/member/persistence/MemberJpa.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import starlight.application.businessplan.required.MemberLookUpPort; import starlight.application.member.required.MemberCommandPort; import starlight.application.member.required.MemberQueryPort; import starlight.domain.member.entity.Member; @@ -12,7 +13,7 @@ @Repository @RequiredArgsConstructor -public class MemberJpa implements MemberQueryPort, MemberCommandPort { +public class MemberJpa implements MemberQueryPort, MemberCommandPort, MemberLookUpPort { private final MemberRepository memberRepository; @@ -33,13 +34,6 @@ public Optional findByProviderAndProviderId(String provider, String prov return memberRepository.findByProviderAndProviderId(provider, providerId); } - @Override - public Member findByProviderAndProviderIdOrThrow(String provider, String providerId) { - return memberRepository.findByProviderAndProviderId(provider, providerId).orElseThrow( - () -> new MemberException(MemberErrorType.MEMBER_NOT_FOUND) - ); - } - @Override public Member save(Member member) { return memberRepository.save(member); diff --git a/src/main/java/starlight/application/businessplan/BusinessPlanService.java b/src/main/java/starlight/application/businessplan/BusinessPlanService.java index c26181c..546eb32 100644 --- a/src/main/java/starlight/application/businessplan/BusinessPlanService.java +++ b/src/main/java/starlight/application/businessplan/BusinessPlanService.java @@ -15,9 +15,9 @@ import starlight.application.businessplan.required.BusinessPlanCommandPort; import starlight.application.businessplan.required.BusinessPlanQueryPort; import starlight.application.businessplan.required.ChecklistGraderPort; +import starlight.application.businessplan.required.MemberLookUpPort; import starlight.application.businessplan.util.PlainTextExtractUtils; import starlight.application.businessplan.util.SubSectionSupportUtils; -import starlight.application.member.required.MemberQueryPort; import starlight.domain.businessplan.entity.*; import starlight.domain.businessplan.enumerate.PlanStatus; import starlight.domain.member.entity.Member; @@ -37,13 +37,13 @@ public class BusinessPlanService implements BusinessPlanUseCase { private final BusinessPlanCommandPort businessPlanCommandPort; private final BusinessPlanQueryPort businessPlanQueryPort; - private final MemberQueryPort memberQueryPort; + private final MemberLookUpPort memberLookUpPort; private final ChecklistGraderPort checklistGrader; private final ObjectMapper objectMapper; @Override public BusinessPlanResult.Result createBusinessPlan(Long memberId) { - Member member = memberQueryPort.findByIdOrThrow(memberId); + Member member = memberLookUpPort.findByIdOrThrow(memberId); String planTitle = member.getName() == null ? "제목 없는 사업계획서" : member.getName() + "의 사업계획서"; diff --git a/src/main/java/starlight/application/businessplan/required/MemberLookUpPort.java b/src/main/java/starlight/application/businessplan/required/MemberLookUpPort.java new file mode 100644 index 0000000..5444524 --- /dev/null +++ b/src/main/java/starlight/application/businessplan/required/MemberLookUpPort.java @@ -0,0 +1,7 @@ +package starlight.application.businessplan.required; + +import starlight.domain.member.entity.Member; + +public interface MemberLookUpPort { + Member findByIdOrThrow(Long id); +} diff --git a/src/main/java/starlight/application/member/required/MemberQueryPort.java b/src/main/java/starlight/application/member/required/MemberQueryPort.java index 71fe15a..7aac87b 100644 --- a/src/main/java/starlight/application/member/required/MemberQueryPort.java +++ b/src/main/java/starlight/application/member/required/MemberQueryPort.java @@ -11,6 +11,4 @@ public interface MemberQueryPort { Optional findByEmail(String email); Optional findByProviderAndProviderId(String provider, String providerId); - - Member findByProviderAndProviderIdOrThrow(String provider, String providerId); } From 05caa79c07e3f55ea9a86b2b150e2756c42eed00 Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Sat, 24 Jan 2026 20:39:44 -0600 Subject: [PATCH 52/74] =?UTF-8?q?[SRLT-124]=20Chore:=20findWithAllSubSecti?= =?UTF-8?q?onsOrThrow=20->=20findByIdWithAllSubSectionsOrThrow=EB=A1=9C=20?= =?UTF-8?q?BusinessPlanQueryPort=EC=9D=98=20=EB=A9=94=EC=86=8C=EB=93=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/businessplan/persistence/BusinessPlanJpa.java | 2 +- .../application/businessplan/BusinessPlanService.java | 2 +- .../businessplan/required/BusinessPlanQueryPort.java | 2 +- .../application/aireport/AiReportServiceIntegrationTest.java | 2 +- .../application/businessplan/BusinessPlanServiceUnitTest.java | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java index ada1b46..d752663 100644 --- a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java +++ b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java @@ -27,7 +27,7 @@ public BusinessPlan findByIdOrThrow(Long id) { } @Override - public BusinessPlan findWithAllSubSectionsOrThrow(Long id) { + public BusinessPlan findByIdWithAllSubSectionsOrThrow(Long id) { return businessPlanRepository.findByIdWithAllSubSections(id).orElseThrow( () -> new BusinessPlanException(BusinessPlanErrorType.BUSINESS_PLAN_NOT_FOUND) ); diff --git a/src/main/java/starlight/application/businessplan/BusinessPlanService.java b/src/main/java/starlight/application/businessplan/BusinessPlanService.java index 546eb32..dc5a964 100644 --- a/src/main/java/starlight/application/businessplan/BusinessPlanService.java +++ b/src/main/java/starlight/application/businessplan/BusinessPlanService.java @@ -74,7 +74,7 @@ public BusinessPlanResult.Result getBusinessPlanInfo(Long planId, Long memberId) @Override @Transactional(readOnly = true) public BusinessPlanResult.Detail getBusinessPlanDetail(Long planId, Long memberId) { - BusinessPlan plan = businessPlanQueryPort.findWithAllSubSectionsOrThrow(planId); + BusinessPlan plan = businessPlanQueryPort.findByIdWithAllSubSectionsOrThrow(planId); if (!plan.isOwnedBy(memberId)) { throw new BusinessPlanException(BusinessPlanErrorType.UNAUTHORIZED_ACCESS); } diff --git a/src/main/java/starlight/application/businessplan/required/BusinessPlanQueryPort.java b/src/main/java/starlight/application/businessplan/required/BusinessPlanQueryPort.java index 1784bc8..e4bfd5e 100644 --- a/src/main/java/starlight/application/businessplan/required/BusinessPlanQueryPort.java +++ b/src/main/java/starlight/application/businessplan/required/BusinessPlanQueryPort.java @@ -8,7 +8,7 @@ public interface BusinessPlanQueryPort { BusinessPlan findByIdOrThrow(Long id); - BusinessPlan findWithAllSubSectionsOrThrow(Long id); + BusinessPlan findByIdWithAllSubSectionsOrThrow(Long id); Page findPreviewPage(Long memberId, Pageable pageable); } diff --git a/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java b/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java index 6dd1917..2f5ad1a 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java @@ -139,7 +139,7 @@ public BusinessPlan findByIdOrThrow(Long id) { } @Override - public BusinessPlan findWithAllSubSectionsOrThrow(Long id) { + public BusinessPlan findByIdWithAllSubSectionsOrThrow(Long id) { return businessPlanRepository.findByIdWithAllSubSections(id) .orElseThrow(() -> new RuntimeException("BusinessPlan not found: " + id)); } diff --git a/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java index b7a09fc..b573003 100644 --- a/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java +++ b/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java @@ -341,7 +341,7 @@ void getBusinessPlanSubSections_returnsExistingSubSectionList() { List.of(false, false, false, false, false)); plan.getProblemRecognition().putSubSection(problem); - when(businessPlanQuery.findWithAllSubSectionsOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.findByIdWithAllSubSectionsOrThrow(1L)).thenReturn(plan); BusinessPlanResult.Detail detail = sut.getBusinessPlanDetail(1L, 10L); @@ -359,7 +359,7 @@ void getBusinessPlanSubSections_returnsExistingSubSectionList() { void getBusinessPlanDetail_unauthorized_throws() { BusinessPlan plan = mock(BusinessPlan.class); when(plan.isOwnedBy(10L)).thenReturn(false); - when(businessPlanQuery.findWithAllSubSectionsOrThrow(1L)).thenReturn(plan); + when(businessPlanQuery.findByIdWithAllSubSectionsOrThrow(1L)).thenReturn(plan); org.junit.jupiter.api.Assertions.assertThrows(BusinessPlanException.class, () -> sut.getBusinessPlanDetail(1L, 10L)); From 143cc50c42056af870a6d60f846b273bbec76693 Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Sat, 24 Jan 2026 21:11:10 -0600 Subject: [PATCH 53/74] =?UTF-8?q?[SRLT-124]=20Chore:=20AiReport=EC=9D=98?= =?UTF-8?q?=20SectionAdvisor=20Config=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20?= =?UTF-8?q?=EB=B0=8F=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=82=98=ED=83=80?= =?UTF-8?q?=EB=82=B4=EC=A3=BC=EA=B8=B0=20=EC=9C=84=ED=95=B4=EC=84=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AiReportSectionAdvisorConfig.java} | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) rename src/main/java/starlight/{adapter/aireport/report/config/SectionAdvisorConfig.java => bootstrap/AiReportSectionAdvisorConfig.java} (95%) diff --git a/src/main/java/starlight/adapter/aireport/report/config/SectionAdvisorConfig.java b/src/main/java/starlight/bootstrap/AiReportSectionAdvisorConfig.java similarity index 95% rename from src/main/java/starlight/adapter/aireport/report/config/SectionAdvisorConfig.java rename to src/main/java/starlight/bootstrap/AiReportSectionAdvisorConfig.java index f1bf069..eacd22b 100644 --- a/src/main/java/starlight/adapter/aireport/report/config/SectionAdvisorConfig.java +++ b/src/main/java/starlight/bootstrap/AiReportSectionAdvisorConfig.java @@ -1,4 +1,4 @@ -package starlight.adapter.aireport.report.config; +package starlight.bootstrap; import lombok.RequiredArgsConstructor; import org.springframework.ai.chat.client.ChatClient; @@ -18,7 +18,7 @@ @Configuration @RequiredArgsConstructor -public class SectionAdvisorConfig { +public class AiReportSectionAdvisorConfig { private final ChatClient.Builder chatClientBuilder; private final ReportPromptProvider reportPromptProvider; From d26ac3e644fbd0284cd870decfa1612c34ad76f8 Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Sat, 24 Jan 2026 22:04:32 -0600 Subject: [PATCH 54/74] =?UTF-8?q?[SRLT-124]=20Refactor:=20AI=EB=A6=AC?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=EC=99=80=20=EC=82=AC=EC=97=85=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=EC=84=9C=20=EB=8F=84=EB=A9=94=EC=9D=B8=EA=B0=84?= =?UTF-8?q?=EC=9D=98=20LookUpPort=20=EC=9E=91=EC=84=B1=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=B4=EC=97=90=20=EB=94=B0=EB=A5=B8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=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 --- .../creation/BusinessPlanCreationAdapter.java | 29 ------------ ...PlanJpa.java => BusinessPlanQueryJpa.java} | 13 ++++- .../application/aireport/AiReportService.java | 23 ++++----- .../BusinessPlanCommandLookUpPort.java | 9 ++++ .../required/BusinessPlanCreationPort.java | 7 --- .../required/BusinessPlanQueryLookUpPort.java | 7 +++ .../expert/ExpertAiReportQueryService.java | 6 +-- ....java => BusinessPlanQueryLookupPort.java} | 2 +- .../AiReportServiceIntegrationTest.java | 47 +++++++++++++------ .../aireport/AiReportServiceUnitTest.java | 39 ++++++++------- .../BusinessPlanServiceIntegrationTest.java | 27 ++--------- .../BusinessPlanServiceUnitTest.java | 8 ++-- 12 files changed, 99 insertions(+), 118 deletions(-) delete mode 100644 src/main/java/starlight/adapter/businessplan/creation/BusinessPlanCreationAdapter.java rename src/main/java/starlight/adapter/businessplan/persistence/{BusinessPlanJpa.java => BusinessPlanQueryJpa.java} (75%) create mode 100644 src/main/java/starlight/application/aireport/required/BusinessPlanCommandLookUpPort.java delete mode 100644 src/main/java/starlight/application/aireport/required/BusinessPlanCreationPort.java create mode 100644 src/main/java/starlight/application/aireport/required/BusinessPlanQueryLookUpPort.java rename src/main/java/starlight/application/expert/required/{BusinessPlanLookupPort.java => BusinessPlanQueryLookupPort.java} (80%) diff --git a/src/main/java/starlight/adapter/businessplan/creation/BusinessPlanCreationAdapter.java b/src/main/java/starlight/adapter/businessplan/creation/BusinessPlanCreationAdapter.java deleted file mode 100644 index c87df3b..0000000 --- a/src/main/java/starlight/adapter/businessplan/creation/BusinessPlanCreationAdapter.java +++ /dev/null @@ -1,29 +0,0 @@ -package starlight.adapter.businessplan.creation; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; -import starlight.application.businessplan.provided.BusinessPlanUseCase; -import starlight.application.businessplan.provided.dto.BusinessPlanResult; -import starlight.application.aireport.required.BusinessPlanCreationPort; - -/** - * BusinessPlanCreationPort의 구현체 - * BusinessPlanUseCase를 래핑하여 필요한 기능만 노출합니다. - */ -@Component -@RequiredArgsConstructor -public class BusinessPlanCreationAdapter implements BusinessPlanCreationPort { - - private final BusinessPlanUseCase businessPlanUseCase; - - @Override - public Long createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId) { - BusinessPlanResult.Result result = businessPlanUseCase.createBusinessPlanWithPdf( - title, - pdfUrl, - memberId - ); - return result.businessPlanId(); - } -} - diff --git a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanQueryJpa.java similarity index 75% rename from src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java rename to src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanQueryJpa.java index d752663..f5dd0d6 100644 --- a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java +++ b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanQueryJpa.java @@ -6,7 +6,6 @@ import org.springframework.stereotype.Repository; import starlight.application.businessplan.required.BusinessPlanCommandPort; import starlight.application.businessplan.required.BusinessPlanQueryPort; -import starlight.application.expert.required.BusinessPlanLookupPort; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.exception.BusinessPlanErrorType; import starlight.domain.businessplan.exception.BusinessPlanException; @@ -15,7 +14,10 @@ @Repository @RequiredArgsConstructor -public class BusinessPlanJpa implements BusinessPlanCommandPort, BusinessPlanQueryPort, BusinessPlanLookupPort { +public class BusinessPlanQueryJpa implements BusinessPlanCommandPort, BusinessPlanQueryPort, + starlight.application.expert.required.BusinessPlanQueryLookupPort, + starlight.application.aireport.required.BusinessPlanCommandLookUpPort, + starlight.application.aireport.required.BusinessPlanQueryLookUpPort { private final BusinessPlanRepository businessPlanRepository; @@ -52,4 +54,11 @@ public Page findPreviewPage(Long memberId, Pageable pageable) { public List findAllByMemberId(Long memberId) { return businessPlanRepository.findAllByMemberIdOrderByLastSavedAt(memberId); } + + @Override + public Long createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId) { + BusinessPlan plan = BusinessPlan.createWithPdf(title, memberId, pdfUrl); + BusinessPlan saved = businessPlanRepository.save(plan); + return saved.getId(); + } } diff --git a/src/main/java/starlight/application/aireport/AiReportService.java b/src/main/java/starlight/application/aireport/AiReportService.java index e4b7ce6..f67411e 100644 --- a/src/main/java/starlight/application/aireport/AiReportService.java +++ b/src/main/java/starlight/application/aireport/AiReportService.java @@ -9,15 +9,9 @@ import org.springframework.transaction.annotation.Transactional; import starlight.application.aireport.provided.AiReportUseCase; import starlight.application.aireport.provided.dto.AiReportResult; -import starlight.application.aireport.required.AiReportCommandPort; -import starlight.application.aireport.required.AiReportQueryPort; -import starlight.application.aireport.required.ReportGraderPort; +import starlight.application.aireport.required.*; import starlight.application.aireport.util.AiReportResponseParser; -import starlight.application.businessplan.required.BusinessPlanCommandPort; -import starlight.application.aireport.required.BusinessPlanCreationPort; -import starlight.application.businessplan.required.BusinessPlanQueryPort; import starlight.application.businessplan.util.BusinessPlanContentExtractor; -import starlight.application.aireport.required.OcrProviderPort; import starlight.domain.aireport.entity.AiReport; import starlight.domain.aireport.exception.AiReportErrorType; import starlight.domain.aireport.exception.AiReportException; @@ -34,9 +28,8 @@ @Transactional public class AiReportService implements AiReportUseCase { - private final BusinessPlanCommandPort businessPlanCommandPort; - private final BusinessPlanQueryPort businessPlanQueryPort; - private final BusinessPlanCreationPort businessPlanCreationPort; + private final BusinessPlanCommandLookUpPort businessPlanCommandLookUpPort; + private final BusinessPlanQueryLookUpPort businessPlanQueryLookUpPort; private final AiReportQueryPort aiReportQueryPort; private final AiReportCommandPort aiReportCommandPort; private final ReportGraderPort reportGrader; @@ -49,7 +42,7 @@ public class AiReportService implements AiReportUseCase { public AiReportResult gradeBusinessPlan(Long planId, Long memberId) { log.info("사업계획서 AI 채점 시작. planId: {}, memberId: {}", planId, memberId); - BusinessPlan plan = businessPlanQueryPort.findByIdOrThrow(planId); + BusinessPlan plan = businessPlanQueryLookUpPort.findByIdOrThrow(planId); checkBusinessPlanOwned(plan, memberId); checkBusinessPlanWritingCompleted(plan); @@ -85,8 +78,8 @@ public AiReportResult gradeBusinessPlan(Long planId, Long memberId) { public AiReportResult createAndGradePdfBusinessPlan(String title, String pdfUrl, Long memberId) { log.info("PDF 사업계획서 생성 및 AI 채점 시작. title: {}, pdfUrl: {}, memberId: {}", title, pdfUrl, memberId); - Long businessPlanId = businessPlanCreationPort.createBusinessPlanWithPdf(title, pdfUrl, memberId); - BusinessPlan plan = businessPlanQueryPort.findByIdOrThrow(businessPlanId); + Long businessPlanId = businessPlanCommandLookUpPort.createBusinessPlanWithPdf(title, pdfUrl, memberId); + BusinessPlan plan = businessPlanQueryLookUpPort.findByIdOrThrow(businessPlanId); log.debug("OCR 시작. pdfUrl: {}", pdfUrl); String pdfText = ocrProvider.ocrPdfTextByUrl(pdfUrl); @@ -118,7 +111,7 @@ public AiReportResult createAndGradePdfBusinessPlan(String title, String pdfUrl, @Override @Transactional(readOnly = true) public AiReportResult getAiReport(Long planId, Long memberId) { - BusinessPlan plan = businessPlanQueryPort.findByIdOrThrow(planId); + BusinessPlan plan = businessPlanQueryLookUpPort.findByIdOrThrow(planId); checkBusinessPlanOwned(plan, memberId); AiReport aiReport = aiReportQueryPort.findByBusinessPlanId(planId) @@ -150,7 +143,7 @@ private AiReport upsertAiReportWithRawJsonStr(String rawJsonString, BusinessPlan } plan.updateStatus(PlanStatus.AI_REVIEWED); - businessPlanCommandPort.save(plan); + businessPlanCommandLookUpPort.save(plan); return aiReportCommandPort.save(aiReport); } diff --git a/src/main/java/starlight/application/aireport/required/BusinessPlanCommandLookUpPort.java b/src/main/java/starlight/application/aireport/required/BusinessPlanCommandLookUpPort.java new file mode 100644 index 0000000..0c25cef --- /dev/null +++ b/src/main/java/starlight/application/aireport/required/BusinessPlanCommandLookUpPort.java @@ -0,0 +1,9 @@ +package starlight.application.aireport.required; + +import starlight.domain.businessplan.entity.BusinessPlan; + +public interface BusinessPlanCommandLookUpPort { + BusinessPlan save(BusinessPlan plan); + + Long createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId); +} diff --git a/src/main/java/starlight/application/aireport/required/BusinessPlanCreationPort.java b/src/main/java/starlight/application/aireport/required/BusinessPlanCreationPort.java deleted file mode 100644 index 58179bf..0000000 --- a/src/main/java/starlight/application/aireport/required/BusinessPlanCreationPort.java +++ /dev/null @@ -1,7 +0,0 @@ -package starlight.application.aireport.required; - -public interface BusinessPlanCreationPort { - - Long createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId); -} - diff --git a/src/main/java/starlight/application/aireport/required/BusinessPlanQueryLookUpPort.java b/src/main/java/starlight/application/aireport/required/BusinessPlanQueryLookUpPort.java new file mode 100644 index 0000000..0024cb7 --- /dev/null +++ b/src/main/java/starlight/application/aireport/required/BusinessPlanQueryLookUpPort.java @@ -0,0 +1,7 @@ +package starlight.application.aireport.required; + +import starlight.domain.businessplan.entity.BusinessPlan; + +public interface BusinessPlanQueryLookUpPort { + BusinessPlan findByIdOrThrow(Long id); +} \ No newline at end of file diff --git a/src/main/java/starlight/application/expert/ExpertAiReportQueryService.java b/src/main/java/starlight/application/expert/ExpertAiReportQueryService.java index 8c472ba..a5c6691 100644 --- a/src/main/java/starlight/application/expert/ExpertAiReportQueryService.java +++ b/src/main/java/starlight/application/expert/ExpertAiReportQueryService.java @@ -6,7 +6,7 @@ import starlight.application.expert.provided.ExpertAiReportQueryUseCase; import starlight.application.expert.provided.dto.ExpertAiReportBusinessPlanResult; import starlight.application.expert.required.AiReportSummaryLookupPort; -import starlight.application.expert.required.BusinessPlanLookupPort; +import starlight.application.expert.required.BusinessPlanQueryLookupPort; import starlight.application.expert.required.ExpertApplicationCountLookupPort; import starlight.domain.businessplan.entity.BusinessPlan; @@ -19,14 +19,14 @@ @Transactional(readOnly = true) public class ExpertAiReportQueryService implements ExpertAiReportQueryUseCase { - private final BusinessPlanLookupPort businessPlanLookupPort; + private final BusinessPlanQueryLookupPort businessPlanQueryLookupPort; private final AiReportSummaryLookupPort aiReportSummaryLookupPort; private final ExpertApplicationCountLookupPort expertApplicationCountLookupPort; @Override public List findAiReportBusinessPlans(Long expertId, Long memberId) { - List plans = businessPlanLookupPort.findAllByMemberId(memberId); + List plans = businessPlanQueryLookupPort.findAllByMemberId(memberId); if (plans.isEmpty()) { return List.of(); } diff --git a/src/main/java/starlight/application/expert/required/BusinessPlanLookupPort.java b/src/main/java/starlight/application/expert/required/BusinessPlanQueryLookupPort.java similarity index 80% rename from src/main/java/starlight/application/expert/required/BusinessPlanLookupPort.java rename to src/main/java/starlight/application/expert/required/BusinessPlanQueryLookupPort.java index 63a11a0..df2e0fd 100644 --- a/src/main/java/starlight/application/expert/required/BusinessPlanLookupPort.java +++ b/src/main/java/starlight/application/expert/required/BusinessPlanQueryLookupPort.java @@ -4,7 +4,7 @@ import java.util.List; -public interface BusinessPlanLookupPort { +public interface BusinessPlanQueryLookupPort { List findAllByMemberId(Long memberId); } diff --git a/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java b/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java index 2f5ad1a..d1da1f1 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java @@ -13,16 +13,17 @@ import starlight.application.aireport.util.AiReportResponseParser; import starlight.adapter.aireport.persistence.AiReportJpa; import starlight.adapter.aireport.persistence.AiReportRepository; -import starlight.adapter.businessplan.persistence.BusinessPlanJpa; +import starlight.adapter.businessplan.persistence.BusinessPlanQueryJpa; import starlight.adapter.businessplan.persistence.BusinessPlanRepository; import starlight.application.aireport.provided.dto.AiReportResult; import starlight.application.aireport.required.AiReportCommandPort; import starlight.application.aireport.required.AiReportQueryPort; -import starlight.application.aireport.required.BusinessPlanCreationPort; import starlight.application.aireport.required.OcrProviderPort; import starlight.application.aireport.required.ReportGraderPort; import starlight.application.businessplan.required.BusinessPlanCommandPort; 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.domain.aireport.entity.AiReport; import starlight.domain.businessplan.entity.BusinessPlan; @@ -37,7 +38,7 @@ @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) -@Import({AiReportService.class, AiReportJpa.class, BusinessPlanJpa.class, AiReportServiceIntegrationTest.TestBeans.class}) +@Import({AiReportService.class, AiReportJpa.class, BusinessPlanQueryJpa.class, AiReportServiceIntegrationTest.TestBeans.class}) @DisplayName("AiReportService 통합 테스트") class AiReportServiceIntegrationTest { @@ -151,18 +152,6 @@ public org.springframework.data.domain.Page findPreviewPage(Long m }; } - @Bean - BusinessPlanCreationPort businessPlanCreationPort(BusinessPlanRepository businessPlanRepository) { - return new BusinessPlanCreationPort() { - @Override - public Long createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId) { - BusinessPlan plan = BusinessPlan.createWithPdf(title, memberId, pdfUrl); - BusinessPlan saved = businessPlanRepository.save(plan); - return saved.getId(); - } - }; - } - @Bean AiReportCommandPort aiReportCommandPort(AiReportRepository aiReportRepository) { return new AiReportCommandPort() { @@ -203,6 +192,34 @@ BusinessPlanContentExtractor businessPlanContentExtractor() { return new BusinessPlanContentExtractor(); } + @Bean + BusinessPlanCommandLookUpPort businessPlanCommandLookUpPort(BusinessPlanRepository businessPlanRepository) { + return new BusinessPlanCommandLookUpPort() { + @Override + public BusinessPlan save(BusinessPlan plan) { + return businessPlanRepository.save(plan); + } + + @Override + public Long createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId) { + BusinessPlan plan = BusinessPlan.createWithPdf(title, memberId, pdfUrl); + BusinessPlan saved = businessPlanRepository.save(plan); + return saved.getId(); + } + }; + } + + @Bean + BusinessPlanQueryLookUpPort businessPlanQueryLookUpPort(BusinessPlanRepository businessPlanRepository) { + return new BusinessPlanQueryLookUpPort() { + @Override + public BusinessPlan findByIdOrThrow(Long id) { + return businessPlanRepository.findById(id) + .orElseThrow(() -> new RuntimeException("BusinessPlan not found: " + id)); + } + }; + } + } /** diff --git a/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java b/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java index 724be39..4529c9a 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java @@ -5,13 +5,12 @@ import org.junit.jupiter.api.Test; import starlight.application.aireport.util.AiReportResponseParser; import starlight.application.aireport.provided.dto.AiReportResult; -import starlight.application.aireport.required.BusinessPlanCreationPort; import starlight.application.aireport.required.ReportGraderPort; import starlight.application.aireport.required.AiReportQueryPort; import starlight.application.aireport.required.AiReportCommandPort; import starlight.application.aireport.required.OcrProviderPort; -import starlight.application.businessplan.required.BusinessPlanQueryPort; -import starlight.application.businessplan.required.BusinessPlanCommandPort; +import starlight.application.aireport.required.BusinessPlanCommandLookUpPort; +import starlight.application.aireport.required.BusinessPlanQueryLookUpPort; import starlight.application.businessplan.util.BusinessPlanContentExtractor; import starlight.domain.aireport.entity.AiReport; import starlight.domain.aireport.exception.AiReportErrorType; @@ -34,9 +33,8 @@ @DisplayName("AiReportService 유닛 테스트") class AiReportServiceUnitTest { - private final BusinessPlanCommandPort businessPlanCommand = mock(BusinessPlanCommandPort.class); - private final BusinessPlanQueryPort businessPlanQuery = mock(BusinessPlanQueryPort.class); - private final BusinessPlanCreationPort businessPlanCreationPort = mock(BusinessPlanCreationPort.class); + private final BusinessPlanCommandLookUpPort businessPlanCommandLookUpPort = mock(BusinessPlanCommandLookUpPort.class); + private final BusinessPlanQueryLookUpPort businessPlanQueryLookUpPort = mock(BusinessPlanQueryLookUpPort.class); private final AiReportQueryPort aiReportQuery = mock(AiReportQueryPort.class); private final AiReportCommandPort aiReportCommand = mock(AiReportCommandPort.class); private final ReportGraderPort aiReportGrader = mock(ReportGraderPort.class); @@ -57,7 +55,7 @@ void gradeBusinessPlan_createsNewReport() { when(plan.getId()).thenReturn(planId); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(true); - when(businessPlanQuery.findByIdOrThrow(planId)).thenReturn(plan); + when(businessPlanQueryLookUpPort.findByIdOrThrow(planId)).thenReturn(plan); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.empty()); String extractedContent = "사업계획서 내용"; @@ -94,9 +92,9 @@ void gradeBusinessPlan_createsNewReport() { when(savedReport.getBusinessPlanId()).thenReturn(planId); when(savedReport.getRawJson()).thenReturn(RawJson.create(rawJson)); when(aiReportCommand.save(any(AiReport.class))).thenReturn(savedReport); - when(businessPlanCommand.save(any(BusinessPlan.class))).thenReturn(plan); + when(businessPlanCommandLookUpPort.save(any(BusinessPlan.class))).thenReturn(plan); - sut = new AiReportService(businessPlanCommand, businessPlanQuery, businessPlanCreationPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookUpPort, businessPlanQueryLookUpPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when AiReportResult result = sut.gradeBusinessPlan(planId, memberId); @@ -105,6 +103,7 @@ void gradeBusinessPlan_createsNewReport() { assertThat(result).isNotNull(); verify(plan).updateStatus(PlanStatus.AI_REVIEWED); verify(aiReportCommand).save(any(AiReport.class)); + verify(businessPlanCommandLookUpPort).save(plan); } @Test @@ -117,7 +116,7 @@ void gradeBusinessPlan_updatesExistingReport() { when(plan.getId()).thenReturn(planId); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(true); - when(businessPlanQuery.findByIdOrThrow(planId)).thenReturn(plan); + when(businessPlanQueryLookUpPort.findByIdOrThrow(planId)).thenReturn(plan); AiReport existingReport = mock(AiReport.class); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.of(existingReport)); @@ -155,9 +154,9 @@ void gradeBusinessPlan_updatesExistingReport() { when(existingReport.getBusinessPlanId()).thenReturn(planId); when(existingReport.getRawJson()).thenReturn(RawJson.create(rawJson)); when(aiReportCommand.save(existingReport)).thenReturn(existingReport); - when(businessPlanCommand.save(any(BusinessPlan.class))).thenReturn(plan); + when(businessPlanCommandLookUpPort.save(any(BusinessPlan.class))).thenReturn(plan); - sut = new AiReportService(businessPlanCommand, businessPlanQuery, businessPlanCreationPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookUpPort, businessPlanQueryLookUpPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when AiReportResult result = sut.gradeBusinessPlan(planId, memberId); @@ -177,9 +176,9 @@ void gradeBusinessPlan_throwsExceptionWhenNotOwner() { Long memberId = 1L; BusinessPlan plan = mock(BusinessPlan.class); when(plan.isOwnedBy(memberId)).thenReturn(false); - when(businessPlanQuery.findByIdOrThrow(planId)).thenReturn(plan); + when(businessPlanQueryLookUpPort.findByIdOrThrow(planId)).thenReturn(plan); - sut = new AiReportService(businessPlanCommand, businessPlanQuery, businessPlanCreationPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookUpPort, businessPlanQueryLookUpPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) @@ -197,9 +196,9 @@ void gradeBusinessPlan_throwsExceptionWhenNotCompleted() { BusinessPlan plan = mock(BusinessPlan.class); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(false); - when(businessPlanQuery.findByIdOrThrow(planId)).thenReturn(plan); + when(businessPlanQueryLookUpPort.findByIdOrThrow(planId)).thenReturn(plan); - sut = new AiReportService(businessPlanCommand, businessPlanQuery, businessPlanCreationPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookUpPort, businessPlanQueryLookUpPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) @@ -218,7 +217,7 @@ void getAiReport_returnsResponse() { when(plan.getId()).thenReturn(planId); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(true); - when(businessPlanQuery.findByIdOrThrow(planId)).thenReturn(plan); + when(businessPlanQueryLookUpPort.findByIdOrThrow(planId)).thenReturn(plan); String rawJson = """ { @@ -237,7 +236,7 @@ void getAiReport_returnsResponse() { when(aiReport.getRawJson()).thenReturn(RawJson.create(rawJson)); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.of(aiReport)); - sut = new AiReportService(businessPlanCommand, businessPlanQuery, businessPlanCreationPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookUpPort, businessPlanQueryLookUpPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when AiReportResult result = sut.getAiReport(planId, memberId); @@ -258,10 +257,10 @@ void getAiReport_throwsExceptionWhenNotFound() { when(plan.getId()).thenReturn(planId); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(true); - when(businessPlanQuery.findByIdOrThrow(planId)).thenReturn(plan); + when(businessPlanQueryLookUpPort.findByIdOrThrow(planId)).thenReturn(plan); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.empty()); - sut = new AiReportService(businessPlanCommand, businessPlanQuery, businessPlanCreationPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookUpPort, businessPlanQueryLookUpPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.getAiReport(planId, memberId)) diff --git a/src/test/java/starlight/application/businessplan/BusinessPlanServiceIntegrationTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceIntegrationTest.java index 802cd2e..6165694 100644 --- a/src/test/java/starlight/application/businessplan/BusinessPlanServiceIntegrationTest.java +++ b/src/test/java/starlight/application/businessplan/BusinessPlanServiceIntegrationTest.java @@ -9,10 +9,10 @@ import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; -import starlight.adapter.businessplan.persistence.BusinessPlanJpa; +import starlight.adapter.businessplan.persistence.BusinessPlanQueryJpa; import starlight.adapter.businessplan.persistence.BusinessPlanRepository; import starlight.application.businessplan.required.ChecklistGraderPort; -import starlight.application.member.required.MemberQueryPort; +import starlight.application.businessplan.required.MemberLookUpPort; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.entity.SubSection; import starlight.domain.businessplan.enumerate.SubSectionType; @@ -26,7 +26,7 @@ @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) -@Import({ BusinessPlanService.class, BusinessPlanJpa.class, +@Import({ BusinessPlanService.class, BusinessPlanQueryJpa.class, BusinessPlanServiceIntegrationTest.TestBeans.class }) class BusinessPlanServiceIntegrationTest { @@ -50,31 +50,14 @@ ObjectMapper objectMapper() { } @Bean - MemberQueryPort memberQuery() { - return new MemberQueryPort() { + MemberLookUpPort memberLookUpPort() { + return new MemberLookUpPort() { @Override public Member findByIdOrThrow(Long memberId) { Member m = mock(Member.class); when(m.getName()).thenReturn("tester"); return m; } - - @Override - public java.util.Optional findByEmail(String email) { - return java.util.Optional.empty(); - } - - @Override - public java.util.Optional findByProviderAndProviderId(String provider, String providerId) { - return java.util.Optional.empty(); - } - - @Override - public Member findByProviderAndProviderIdOrThrow(String provider, String providerId) { - Member m = mock(Member.class); - when(m.getName()).thenReturn("tester"); - return m; - } }; } } diff --git a/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java index b573003..83b2571 100644 --- a/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java +++ b/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java @@ -21,7 +21,7 @@ import starlight.domain.businessplan.enumerate.SubSectionType; import starlight.domain.businessplan.exception.BusinessPlanException; import starlight.shared.enumerate.SectionType; -import starlight.application.member.required.MemberQueryPort; +import starlight.application.businessplan.required.MemberLookUpPort; import starlight.domain.member.entity.Member; import java.util.List; @@ -50,7 +50,7 @@ class BusinessPlanServiceUnitTest { private ObjectMapper objectMapper; @Mock - private MemberQueryPort memberQuery; + private MemberLookUpPort memberLookUpPort; @InjectMocks private BusinessPlanService sut; @@ -67,10 +67,10 @@ void setup() { when(objectMapper.writeValueAsString(any())).thenReturn("{}"); } catch (Exception ignored) { } - // memberQuery 기본 스텁 + // memberLookUpPort 기본 스텁 Member stubMember = mock(Member.class); when(stubMember.getName()).thenReturn("tester"); - when(memberQuery.findByIdOrThrow(anyLong())).thenReturn(stubMember); + when(memberLookUpPort.findByIdOrThrow(anyLong())).thenReturn(stubMember); } @Test From 4ca309ca176f7ce70d85561f5834da63b9f15cb8 Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Sat, 24 Jan 2026 22:42:23 -0600 Subject: [PATCH 55/74] =?UTF-8?q?[SRLT-124]=20Refactor:=20AI=EB=A6=AC?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=EC=99=80=20=EC=82=AC=EC=97=85=EA=B3=84?= =?UTF-8?q?=ED=9A=8D=EC=84=9C=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC?= =?UTF-8?q?=EC=9D=98=20ApiDocs=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aireport/webapi/AiReportController.java | 11 +- .../webapi/swagger/AiReportApiDoc.java | 297 +++++++ .../webapi/BusinessPlanController.java | 24 +- .../webapi/swagger/BusinessPlanApiDoc.java | 735 ++++++++++++++++++ 4 files changed, 1038 insertions(+), 29 deletions(-) create mode 100644 src/main/java/starlight/adapter/aireport/webapi/swagger/AiReportApiDoc.java create mode 100644 src/main/java/starlight/adapter/businessplan/webapi/swagger/BusinessPlanApiDoc.java diff --git a/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java b/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java index 6d917d0..fd731c4 100644 --- a/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java +++ b/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java @@ -1,13 +1,11 @@ package starlight.adapter.aireport.webapi; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import starlight.adapter.aireport.webapi.swagger.AiReportApiDoc; import starlight.adapter.businessplan.webapi.dto.BusinessPlanCreateWithPdfRequest; import starlight.adapter.member.auth.security.auth.AuthDetails; import starlight.application.aireport.provided.dto.AiReportResult; @@ -18,13 +16,10 @@ @RestController @RequiredArgsConstructor @RequestMapping("/v1/ai-reports") -@Tag(name = "AI 리포트", description = "AI 리포트 채점 및 조회 API") -@SecurityRequirement(name = "bearerAuth") -public class AiReportController { +public class AiReportController implements AiReportApiDoc { private final AiReportUseCase aiReportUseCase; - @Operation(summary = "사업계획서를 AI로 채점 및 생성합니다.") @PostMapping("/evaluation/{planId}") public ApiResponse gradeBusinessPlan( @AuthenticationPrincipal AuthDetails authDetails, @@ -33,7 +28,6 @@ public ApiResponse gradeBusinessPlan( return ApiResponse.success(aiReportUseCase.gradeBusinessPlan(planId, authDetails.getMemberId())); } - @Operation(summary = "PDF URL을 기반으로 사업계획서를 생성하고, AI로 채점 및 생성합니다.") @PostMapping("/evaluation/pdf") public ApiResponse createAndGradeBusinessPlan( @AuthenticationPrincipal AuthDetails authDetails, @@ -46,7 +40,6 @@ public ApiResponse createAndGradeBusinessPlan( )); } - @Operation(summary = "AI 리포트를 조회합니다.") @GetMapping("/{planId}") public ApiResponse getAiReport( @AuthenticationPrincipal AuthDetails authDetails, diff --git a/src/main/java/starlight/adapter/aireport/webapi/swagger/AiReportApiDoc.java b/src/main/java/starlight/adapter/aireport/webapi/swagger/AiReportApiDoc.java new file mode 100644 index 0000000..72a600c --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/webapi/swagger/AiReportApiDoc.java @@ -0,0 +1,297 @@ +package starlight.adapter.aireport.webapi.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import starlight.adapter.businessplan.webapi.dto.BusinessPlanCreateWithPdfRequest; +import starlight.adapter.member.auth.security.auth.AuthDetails; +import starlight.application.aireport.provided.dto.AiReportResult; +import starlight.shared.apiPayload.response.ApiResponse; + +@Tag(name = "AI 리포트", description = "AI 리포트 채점 및 조회 API") +@SecurityRequirement(name = "bearerAuth") +public interface AiReportApiDoc { + + @Operation( + summary = "사업계획서를 AI로 채점 및 생성합니다.", + description = "작성 완료된 사업계획서를 AI로 채점하여 리포트를 생성합니다. 기존 리포트가 있으면 업데이트합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = AiReportResult.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "NOT_READY_FOR_AI_REPORT", + "message": "사업계획서가 작성 완료되지 않아 AI 리포트를 생성할 수 없습니다." + } + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "권한 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "UNAUTHORIZED_ACCESS", + "message": "권한이 없습니다." + } + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "사업계획서 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "BUSINESS_PLAN_NOT_FOUND", + "message": "해당 사업계획서가 존재하지 않습니다." + } + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "AI 채점 실패", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject( + name = "AI 채점 실패", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "AI_GRADING_FAILED", + "message": "AI 채점에 실패했습니다." + } + } + """ + ), + @ExampleObject( + name = "AI 응답 파싱 실패", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "AI_RESPONSE_PARSING_FAILED", + "message": "AI 응답 파싱에 실패했습니다." + } + } + """ + ) + } + ) + ) + }) + @PostMapping("/evaluation/{planId}") + ApiResponse gradeBusinessPlan( + @AuthenticationPrincipal AuthDetails authDetails, + @PathVariable Long planId + ); + + @Operation( + summary = "PDF URL을 기반으로 사업계획서를 생성하고, AI로 채점 및 생성합니다.", + description = "PDF URL을 제공하여 사업계획서를 생성하고, OCR로 텍스트를 추출한 후 AI로 채점하여 리포트를 생성합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = AiReportResult.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 오류", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject( + name = "요청 데이터 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "VALIDATION_ERROR", + "message": "요청 데이터가 유효하지 않습니다." + } + } + """ + ), + @ExampleObject( + name = "OCR 실패", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "OCR_FAILED", + "message": "PDF에서 텍스트를 추출하는데 실패했습니다." + } + } + """ + ) + } + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "AI 채점 실패", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject( + name = "AI 채점 실패", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "AI_GRADING_FAILED", + "message": "AI 채점에 실패했습니다." + } + } + """ + ), + @ExampleObject( + name = "AI 응답 파싱 실패", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "AI_RESPONSE_PARSING_FAILED", + "message": "AI 응답 파싱에 실패했습니다." + } + } + """ + ) + } + ) + ) + }) + @PostMapping("/evaluation/pdf") + ApiResponse createAndGradeBusinessPlan( + @AuthenticationPrincipal AuthDetails authDetails, + @Valid @RequestBody BusinessPlanCreateWithPdfRequest request + ); + + @Operation( + summary = "AI 리포트를 조회합니다.", + description = "지정된 사업계획서의 AI 리포트를 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = AiReportResult.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "조회 실패", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject( + name = "사업계획서 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "BUSINESS_PLAN_NOT_FOUND", + "message": "해당 사업계획서가 존재하지 않습니다." + } + } + """ + ), + @ExampleObject( + name = "AI 리포트 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "AI_REPORT_NOT_FOUND", + "message": "해당 AI 리포트가 존재하지 않습니다." + } + } + """ + ) + } + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "권한 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "UNAUTHORIZED_ACCESS", + "message": "권한이 없습니다." + } + } + """ + ) + ) + ) + }) + @GetMapping("/{planId}") + ApiResponse getAiReport( + @AuthenticationPrincipal AuthDetails authDetails, + @PathVariable Long planId + ); +} diff --git a/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java b/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java index 63f8444..28f60a9 100644 --- a/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java +++ b/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java @@ -1,10 +1,6 @@ package starlight.adapter.businessplan.webapi; import com.fasterxml.jackson.databind.ObjectMapper; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; @@ -17,6 +13,7 @@ import starlight.adapter.businessplan.webapi.dto.BusinessPlanCreateRequest; import starlight.adapter.businessplan.webapi.dto.BusinessPlanCreateWithPdfRequest; import starlight.adapter.businessplan.webapi.dto.SubSectionCreateRequest; +import starlight.adapter.businessplan.webapi.swagger.BusinessPlanApiDoc; import starlight.application.businessplan.provided.dto.BusinessPlanResult; import starlight.application.businessplan.provided.dto.SubSectionResult; import starlight.application.businessplan.provided.BusinessPlanUseCase; @@ -29,19 +26,16 @@ @RestController @RequiredArgsConstructor @RequestMapping("/v1/business-plans") -@Tag(name = "사업계획서", description = "사업계획서 API") -@SecurityRequirement(name = "bearerAuth") -public class BusinessPlanController { +public class BusinessPlanController implements BusinessPlanApiDoc { private final BusinessPlanUseCase businessPlanService; private final ObjectMapper objectMapper; @GetMapping - @Operation(summary = "사업 계획서 목록을 조회합니다. (마이페이지 용)") public ApiResponse getBusinessPlanList( @AuthenticationPrincipal AuthDetails authDetails, - @Parameter(description = "페이지 번호 (1 이상 정수 / 기본 1)") @RequestParam(defaultValue = "1") @Min(1)int page, - @Parameter(description = "페이지 크기 (1 이상 정수 / 기본 3)") @RequestParam(defaultValue = "3") @Min(1) int size + @RequestParam(defaultValue = "1") @Min(1) int page, + @RequestParam(defaultValue = "3") @Min(1) int size ) { int zeroBasedPage = Math.max(0, page - 1); Pageable pageable = PageRequest.of(zeroBasedPage, size); @@ -51,7 +45,6 @@ public ApiResponse getBusinessPlanList( } @GetMapping("/{planId}/subsections") - @Operation(summary = "사업 계획서의 제목과 모든 서브섹션 내용을 조회합니다. (미리보기 용)") public ApiResponse getBusinessPlanDetail( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId @@ -62,7 +55,6 @@ public ApiResponse getBusinessPlanDetail( } @GetMapping("/{planId}/titles") - @Operation(summary = "사업 계획서의 제목을 조회합니다.") public ApiResponse getBusinessPlanTitle( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId @@ -74,7 +66,6 @@ public ApiResponse getBusinessPlanTitle( } @PostMapping - @Operation(summary = "사업 계획서를 생성합니다.") public ApiResponse createBusinessPlan( @AuthenticationPrincipal AuthDetails authDetails ) { @@ -82,7 +73,6 @@ public ApiResponse createBusinessPlan( } @PostMapping("/pdf") - @Operation(summary = "PDF URL을 기반으로 사업계획서를 생성합니다.") public ApiResponse createBusinessPlanWithPdfAndAiReport( @AuthenticationPrincipal AuthDetails authDetails, @Valid @RequestBody BusinessPlanCreateWithPdfRequest request @@ -93,7 +83,6 @@ public ApiResponse createBusinessPlanWithPdfAndAiRepo } @PatchMapping("/{planId}") - @Operation(summary = "사업 계획서 제목을 수정합니다.") public ApiResponse updateBusinessPlanTitle( @AuthenticationPrincipal AuthDetails authDetails, @RequestBody @Valid BusinessPlanCreateRequest request, @@ -104,7 +93,6 @@ public ApiResponse updateBusinessPlanTitle( )); } - @Operation(summary = "사업 계획서를 삭제합니다.") @DeleteMapping("/{planId}") public ApiResponse deleteBusinessPlan( @AuthenticationPrincipal AuthDetails authDetails, @@ -115,7 +103,6 @@ public ApiResponse deleteBusinessPlan( )); } - @Operation(summary = "서브섹션을 생성 또는 수정합니다.") @PostMapping("/{planId}/subsections") public ApiResponse upsertSubSection( @AuthenticationPrincipal AuthDetails authDetails, @@ -127,7 +114,6 @@ public ApiResponse upsertSubSection( )); } - @Operation(summary = "서브섹션을 조회합니다.") @GetMapping("/{planId}/subsections/{subSectionType}") public ApiResponse getSubSection( @AuthenticationPrincipal AuthDetails authDetails, @@ -139,7 +125,6 @@ public ApiResponse getSubSection( )); } - @Operation(summary = "서브섹션의 체크리스트를 점검 후 업데이트합니다.") @PostMapping("/{planId}/subsections/check-and-update") public ApiResponse> checkAndUpdateSubSection( @AuthenticationPrincipal AuthDetails authDetails, @@ -151,7 +136,6 @@ public ApiResponse> checkAndUpdateSubSection( )); } - @Operation(summary = "서브섹션을 삭제합니다.") @DeleteMapping("/{planId}/subsections/{subSectionType}") public ApiResponse deleteSubSection( @AuthenticationPrincipal AuthDetails authDetails, diff --git a/src/main/java/starlight/adapter/businessplan/webapi/swagger/BusinessPlanApiDoc.java b/src/main/java/starlight/adapter/businessplan/webapi/swagger/BusinessPlanApiDoc.java new file mode 100644 index 0000000..0d6a74e --- /dev/null +++ b/src/main/java/starlight/adapter/businessplan/webapi/swagger/BusinessPlanApiDoc.java @@ -0,0 +1,735 @@ +package starlight.adapter.businessplan.webapi.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import starlight.adapter.businessplan.webapi.dto.BusinessPlanCreateRequest; +import starlight.adapter.businessplan.webapi.dto.BusinessPlanCreateWithPdfRequest; +import starlight.adapter.businessplan.webapi.dto.SubSectionCreateRequest; +import starlight.adapter.member.auth.security.auth.AuthDetails; +import starlight.application.businessplan.provided.dto.BusinessPlanResult; +import starlight.application.businessplan.provided.dto.SubSectionResult; +import starlight.domain.businessplan.enumerate.SubSectionType; +import starlight.shared.apiPayload.response.ApiResponse; + +import java.util.List; + +@Tag(name = "사업계획서", description = "사업계획서 API") +@SecurityRequirement(name = "bearerAuth") +public interface BusinessPlanApiDoc { + + @Operation( + summary = "사업 계획서 목록을 조회합니다. (마이페이지 용)", + description = "로그인한 사용자의 사업계획서 목록을 페이지네이션으로 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = BusinessPlanResult.PreviewPage.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "권한 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "UNAUTHORIZED_ACCESS", + "message": "권한이 없습니다." + } + } + """ + ) + ) + ) + }) + @GetMapping + ApiResponse getBusinessPlanList( + @AuthenticationPrincipal AuthDetails authDetails, + @Parameter(description = "페이지 번호 (1 이상 정수 / 기본 1)") @RequestParam(defaultValue = "1") @Min(1) int page, + @Parameter(description = "페이지 크기 (1 이상 정수 / 기본 3)") @RequestParam(defaultValue = "3") @Min(1) int size + ); + + @Operation( + summary = "사업 계획서의 제목과 모든 서브섹션 내용을 조회합니다. (미리보기 용)", + description = "지정된 사업계획서의 제목과 모든 서브섹션 내용을 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = BusinessPlanResult.Detail.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "사업계획서 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "BUSINESS_PLAN_NOT_FOUND", + "message": "해당 사업계획서가 존재하지 않습니다." + } + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "권한 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "UNAUTHORIZED_ACCESS", + "message": "권한이 없습니다." + } + } + """ + ) + ) + ) + }) + @GetMapping("/{planId}/subsections") + ApiResponse getBusinessPlanDetail( + @AuthenticationPrincipal AuthDetails authDetails, + @PathVariable Long planId + ); + + @Operation( + summary = "사업 계획서의 제목을 조회합니다.", + description = "지정된 사업계획서의 제목만 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "SUCCESS", + "data": "나의 사업계획서", + "error": null + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "사업계획서 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "BUSINESS_PLAN_NOT_FOUND", + "message": "해당 사업계획서가 존재하지 않습니다." + } + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "권한 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "UNAUTHORIZED_ACCESS", + "message": "권한이 없습니다." + } + } + """ + ) + ) + ) + }) + @GetMapping("/{planId}/titles") + ApiResponse getBusinessPlanTitle( + @AuthenticationPrincipal AuthDetails authDetails, + @PathVariable Long planId + ); + + @Operation( + summary = "사업 계획서를 생성합니다.", + description = "새로운 사업계획서를 생성합니다. 기본 제목은 사용자 이름 + '의 사업계획서'로 설정됩니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = BusinessPlanResult.Result.class) + ) + ) + }) + @PostMapping + ApiResponse createBusinessPlan( + @AuthenticationPrincipal AuthDetails authDetails + ); + + @Operation( + summary = "PDF URL을 기반으로 사업계획서를 생성합니다.", + description = "PDF URL을 제공하여 사업계획서를 생성합니다. PDF는 OCR을 통해 텍스트로 변환됩니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = BusinessPlanResult.Result.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "VALIDATION_ERROR", + "message": "요청 데이터가 유효하지 않습니다." + } + } + """ + ) + ) + ) + }) + @PostMapping("/pdf") + ApiResponse createBusinessPlanWithPdfAndAiReport( + @AuthenticationPrincipal AuthDetails authDetails, + @Valid @RequestBody BusinessPlanCreateWithPdfRequest request + ); + + @Operation( + summary = "사업 계획서 제목을 수정합니다.", + description = "지정된 사업계획서의 제목을 수정합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "SUCCESS", + "data": "수정된 제목", + "error": null + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "사업계획서 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "BUSINESS_PLAN_NOT_FOUND", + "message": "해당 사업계획서가 존재하지 않습니다." + } + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "권한 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "UNAUTHORIZED_ACCESS", + "message": "권한이 없습니다." + } + } + """ + ) + ) + ) + }) + @PatchMapping("/{planId}") + ApiResponse updateBusinessPlanTitle( + @AuthenticationPrincipal AuthDetails authDetails, + @RequestBody @Valid BusinessPlanCreateRequest request, + @PathVariable Long planId + ); + + @Operation( + summary = "사업 계획서를 삭제합니다.", + description = "지정된 사업계획서를 삭제합니다. 서브섹션도 함께 삭제됩니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = BusinessPlanResult.Result.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "사업계획서 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "BUSINESS_PLAN_NOT_FOUND", + "message": "해당 사업계획서가 존재하지 않습니다." + } + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "권한 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "UNAUTHORIZED_ACCESS", + "message": "권한이 없습니다." + } + } + """ + ) + ) + ) + }) + @DeleteMapping("/{planId}") + ApiResponse deleteBusinessPlan( + @AuthenticationPrincipal AuthDetails authDetails, + @PathVariable Long planId + ); + + @Operation( + summary = "서브섹션을 생성 또는 수정합니다.", + description = "지정된 사업계획서의 서브섹션을 생성하거나 수정합니다. 서브섹션이 존재하지 않으면 생성하고, 존재하면 업데이트합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SubSectionResult.Result.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 오류", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject( + name = "rawJson 누락", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "REQUEST_EMPTY_RAW_JSON", + "message": "rawJson은 null 이 될 수 없습니다." + } + } + """ + ), + @ExampleObject( + name = "rawJson 직렬화 실패", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "RAW_JSON_SERIALIZATION_FAILURE", + "message": "rawJson 직렬화에 실패했습니다." + } + } + """ + ), + @ExampleObject( + name = "checks 리스트 크기 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "CHECKS_LIST_SIZE_INVALID", + "message": "checks 리스트는 길이 5 여야 합니다." + } + } + """ + ) + } + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "사업계획서 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "BUSINESS_PLAN_NOT_FOUND", + "message": "해당 사업계획서가 존재하지 않습니다." + } + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "권한 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "UNAUTHORIZED_ACCESS", + "message": "권한이 없습니다." + } + } + """ + ) + ) + ) + }) + @PostMapping("/{planId}/subsections") + ApiResponse upsertSubSection( + @AuthenticationPrincipal AuthDetails authDetails, + @PathVariable Long planId, + @Valid @RequestBody SubSectionCreateRequest request + ); + + @Operation( + summary = "서브섹션을 조회합니다.", + description = "지정된 사업계획서의 특정 서브섹션을 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SubSectionResult.Detail.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "조회 실패", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject( + name = "사업계획서 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "BUSINESS_PLAN_NOT_FOUND", + "message": "해당 사업계획서가 존재하지 않습니다." + } + } + """ + ), + @ExampleObject( + name = "서브섹션 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "SUBSECTION_NOT_FOUND", + "message": "해당 서브 섹션이 존재하지 않습니다." + } + } + """ + ) + } + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "권한 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "UNAUTHORIZED_ACCESS", + "message": "권한이 없습니다." + } + } + """ + ) + ) + ) + }) + @GetMapping("/{planId}/subsections/{subSectionType}") + ApiResponse getSubSection( + @AuthenticationPrincipal AuthDetails authDetails, + @PathVariable Long planId, + @PathVariable SubSectionType subSectionType + ); + + @Operation( + summary = "서브섹션의 체크리스트를 점검 후 업데이트합니다.", + description = "서브섹션의 내용을 AI로 체크리스트 점검한 후 결과를 업데이트합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "SUCCESS", + "data": [true, false, true, false, true], + "error": null + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 오류", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "CHECKS_LIST_SIZE_INVALID", + "message": "checks 리스트는 길이 5 여야 합니다." + } + } + """ + ) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "조회 실패", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject( + name = "사업계획서 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "BUSINESS_PLAN_NOT_FOUND", + "message": "해당 사업계획서가 존재하지 않습니다." + } + } + """ + ), + @ExampleObject( + name = "서브섹션 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "SUBSECTION_NOT_FOUND", + "message": "해당 서브 섹션이 존재하지 않습니다." + } + } + """ + ) + } + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "권한 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "UNAUTHORIZED_ACCESS", + "message": "권한이 없습니다." + } + } + """ + ) + ) + ) + }) + @PostMapping("/{planId}/subsections/check-and-update") + ApiResponse> checkAndUpdateSubSection( + @AuthenticationPrincipal AuthDetails authDetails, + @PathVariable Long planId, + @Valid @RequestBody SubSectionCreateRequest request + ); + + @Operation( + summary = "서브섹션을 삭제합니다.", + description = "지정된 사업계획서의 특정 서브섹션을 삭제합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = SubSectionResult.Result.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "조회 실패", + content = @Content( + mediaType = "application/json", + examples = { + @ExampleObject( + name = "사업계획서 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "BUSINESS_PLAN_NOT_FOUND", + "message": "해당 사업계획서가 존재하지 않습니다." + } + } + """ + ), + @ExampleObject( + name = "서브섹션 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "SUBSECTION_NOT_FOUND", + "message": "해당 서브 섹션이 존재하지 않습니다." + } + } + """ + ) + } + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "403", + description = "권한 없음", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "UNAUTHORIZED_ACCESS", + "message": "권한이 없습니다." + } + } + """ + ) + ) + ) + }) + @DeleteMapping("/{planId}/subsections/{subSectionType}") + ApiResponse deleteSubSection( + @AuthenticationPrincipal AuthDetails authDetails, + @PathVariable Long planId, + @PathVariable SubSectionType subSectionType + ); +} From bf6f561d30b94d438e7c70231ff6411e98bfa93d Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Sat, 24 Jan 2026 23:34:48 -0600 Subject: [PATCH 56/74] =?UTF-8?q?[SRLT-124]=20Refactor:=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=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 --- .../agent/impl/SpringAiSectionGradeAgent.java | 27 +++++++-- .../aireport/webapi/AiReportController.java | 32 ++++++----- .../dto/AiReportCreateWithPdfRequest.java | 11 ++++ .../aireport/webapi/dto/AiReportResponse.java | 57 +++++++++++++++++++ .../webapi/swagger/AiReportApiDoc.java | 18 +++--- .../application/aireport/AiReportService.java | 2 +- .../aireport/exception/AiReportException.java | 4 ++ .../apiPayload/exception/GlobalException.java | 5 ++ 8 files changed, 129 insertions(+), 27 deletions(-) create mode 100644 src/main/java/starlight/adapter/aireport/webapi/dto/AiReportCreateWithPdfRequest.java create mode 100644 src/main/java/starlight/adapter/aireport/webapi/dto/AiReportResponse.java 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 f98abad..a94619b 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 @@ -13,7 +13,6 @@ import starlight.adapter.aireport.report.provider.SpringAiAdvisorProvider; import starlight.adapter.aireport.report.provider.ReportPromptProvider; import starlight.application.aireport.util.AiReportResponseParser; -import starlight.application.aireport.util.SectionScoreExtractor; import starlight.application.aireport.provided.dto.AiReportResult; import starlight.shared.enumerate.SectionType; @@ -100,9 +99,6 @@ private SectionGradingResult parseSectionResult(String llmResponse) { // 섹션별 응답 파싱 메소드 사용 AiReportResult sectionResponse = responseParser.parseSectionResponse(llmResponse); - // SectionScoreExtractor를 사용하여 점수 추출 - Integer score = SectionScoreExtractor.extractScore(getSectionType(), sectionResponse); - // sectionScores에서 해당 섹션 찾기 String sectionTypeString = getSectionType().name(); AiReportResult.SectionScoreDetailResponse sectionScore = sectionResponse.sectionScores().stream() @@ -110,6 +106,19 @@ private SectionGradingResult parseSectionResult(String llmResponse) { .findFirst() .orElse(null); + if (sectionScore == null) { + return SectionGradingResult.failure( + getSectionType(), + "섹션 점수 누락: sectionScore 없음 (섹션: " + getSectionType() + ")"); + } + + Integer score = getRawScoreForSection(sectionResponse); + if (score == null) { + return SectionGradingResult.failure( + getSectionType(), + "섹션 점수 누락: score 없음 (섹션: " + getSectionType() + ")"); + } + return SectionGradingResult.success(getSectionType(), score, sectionScore); } catch (Exception e) { @@ -117,4 +126,14 @@ private SectionGradingResult parseSectionResult(String llmResponse) { return SectionGradingResult.failure(getSectionType(), "파싱 실패: " + e.getMessage()); } } + + private Integer getRawScoreForSection(AiReportResult result) { + return switch (getSectionType()) { + case PROBLEM_RECOGNITION -> result.problemRecognitionScore(); + case FEASIBILITY -> result.feasibilityScore(); + case GROWTH_STRATEGY -> result.growthStrategyScore(); + case TEAM_COMPETENCE -> result.teamCompetenceScore(); + default -> null; + }; + } } diff --git a/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java b/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java index fd731c4..da85592 100644 --- a/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java +++ b/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java @@ -5,10 +5,10 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; +import starlight.adapter.aireport.webapi.dto.AiReportCreateWithPdfRequest; +import starlight.adapter.aireport.webapi.dto.AiReportResponse; import starlight.adapter.aireport.webapi.swagger.AiReportApiDoc; -import starlight.adapter.businessplan.webapi.dto.BusinessPlanCreateWithPdfRequest; import starlight.adapter.member.auth.security.auth.AuthDetails; -import starlight.application.aireport.provided.dto.AiReportResult; import starlight.application.aireport.provided.AiReportUseCase; import starlight.shared.apiPayload.response.ApiResponse; @@ -21,30 +21,36 @@ public class AiReportController implements AiReportApiDoc { private final AiReportUseCase aiReportUseCase; @PostMapping("/evaluation/{planId}") - public ApiResponse gradeBusinessPlan( + public ApiResponse gradeBusinessPlan( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId ) { - return ApiResponse.success(aiReportUseCase.gradeBusinessPlan(planId, authDetails.getMemberId())); + return ApiResponse.success( + AiReportResponse.from(aiReportUseCase.gradeBusinessPlan(planId, authDetails.getMemberId())) + ); } @PostMapping("/evaluation/pdf") - public ApiResponse createAndGradeBusinessPlan( + public ApiResponse createAndGradeBusinessPlan( @AuthenticationPrincipal AuthDetails authDetails, - @Valid @RequestBody BusinessPlanCreateWithPdfRequest request + @Valid @RequestBody AiReportCreateWithPdfRequest request ) { - return ApiResponse.success(aiReportUseCase.createAndGradePdfBusinessPlan( - request.title(), - request.pdfUrl(), - authDetails.getMemberId() - )); + return ApiResponse.success( + AiReportResponse.from(aiReportUseCase.createAndGradePdfBusinessPlan( + request.title(), + request.pdfUrl(), + authDetails.getMemberId() + )) + ); } @GetMapping("/{planId}") - public ApiResponse getAiReport( + public ApiResponse getAiReport( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId ) { - return ApiResponse.success(aiReportUseCase.getAiReport(planId, authDetails.getMemberId())); + return ApiResponse.success( + AiReportResponse.from(aiReportUseCase.getAiReport(planId, authDetails.getMemberId())) + ); } } diff --git a/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportCreateWithPdfRequest.java b/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportCreateWithPdfRequest.java new file mode 100644 index 0000000..87ea1c2 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportCreateWithPdfRequest.java @@ -0,0 +1,11 @@ +package starlight.adapter.aireport.webapi.dto; + +import jakarta.validation.constraints.NotBlank; + +public record AiReportCreateWithPdfRequest( + @NotBlank(message = "제목은 필수입니다.") + String title, + + @NotBlank(message = "PDF URL은 필수입니다.") + String pdfUrl +) {} diff --git a/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportResponse.java b/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportResponse.java new file mode 100644 index 0000000..485f488 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportResponse.java @@ -0,0 +1,57 @@ +package starlight.adapter.aireport.webapi.dto; + +import com.fasterxml.jackson.annotation.JsonRawValue; +import java.util.List; +import starlight.application.aireport.provided.dto.AiReportResult; + +/** + * AI 리포트 API 응답 DTO (Web 출력용) + * Application 레이어의 AiReportResult를 변환하여 사용한다. + */ +public record AiReportResponse( + Long id, + Long businessPlanId, + Integer totalScore, + Integer problemRecognitionScore, + Integer feasibilityScore, + Integer growthStrategyScore, + Integer teamCompetenceScore, + List sectionScores, + List strengths, + List weaknesses +) { + public record SectionScoreDetailResponse( + String sectionType, + @JsonRawValue String gradingListScores + ) {} + + public record StrengthWeakness( + String title, + String content + ) {} + + public static AiReportResponse from(AiReportResult result) { + List sectionScores = result.sectionScores().stream() + .map(s -> new SectionScoreDetailResponse(s.sectionType(), s.gradingListScores())) + .toList(); + List strengths = result.strengths().stream() + .map(s -> new StrengthWeakness(s.title(), s.content())) + .toList(); + List weaknesses = result.weaknesses().stream() + .map(w -> new StrengthWeakness(w.title(), w.content())) + .toList(); + + return new AiReportResponse( + result.id(), + result.businessPlanId(), + result.totalScore(), + result.problemRecognitionScore(), + result.feasibilityScore(), + result.growthStrategyScore(), + result.teamCompetenceScore(), + sectionScores, + strengths, + weaknesses + ); + } +} diff --git a/src/main/java/starlight/adapter/aireport/webapi/swagger/AiReportApiDoc.java b/src/main/java/starlight/adapter/aireport/webapi/swagger/AiReportApiDoc.java index 72a600c..437ae02 100644 --- a/src/main/java/starlight/adapter/aireport/webapi/swagger/AiReportApiDoc.java +++ b/src/main/java/starlight/adapter/aireport/webapi/swagger/AiReportApiDoc.java @@ -10,9 +10,9 @@ import jakarta.validation.Valid; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -import starlight.adapter.businessplan.webapi.dto.BusinessPlanCreateWithPdfRequest; +import starlight.adapter.aireport.webapi.dto.AiReportCreateWithPdfRequest; +import starlight.adapter.aireport.webapi.dto.AiReportResponse; import starlight.adapter.member.auth.security.auth.AuthDetails; -import starlight.application.aireport.provided.dto.AiReportResult; import starlight.shared.apiPayload.response.ApiResponse; @Tag(name = "AI 리포트", description = "AI 리포트 채점 및 조회 API") @@ -29,7 +29,7 @@ public interface AiReportApiDoc { description = "성공", content = @Content( mediaType = "application/json", - schema = @Schema(implementation = AiReportResult.class) + schema = @Schema(implementation = AiReportResponse.class) ) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( @@ -126,7 +126,7 @@ public interface AiReportApiDoc { ) }) @PostMapping("/evaluation/{planId}") - ApiResponse gradeBusinessPlan( + ApiResponse gradeBusinessPlan( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId ); @@ -141,7 +141,7 @@ ApiResponse gradeBusinessPlan( description = "성공", content = @Content( mediaType = "application/json", - schema = @Schema(implementation = AiReportResult.class) + schema = @Schema(implementation = AiReportResponse.class) ) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( @@ -216,9 +216,9 @@ ApiResponse gradeBusinessPlan( ) }) @PostMapping("/evaluation/pdf") - ApiResponse createAndGradeBusinessPlan( + ApiResponse createAndGradeBusinessPlan( @AuthenticationPrincipal AuthDetails authDetails, - @Valid @RequestBody BusinessPlanCreateWithPdfRequest request + @Valid @RequestBody AiReportCreateWithPdfRequest request ); @Operation( @@ -231,7 +231,7 @@ ApiResponse createAndGradeBusinessPlan( description = "성공", content = @Content( mediaType = "application/json", - schema = @Schema(implementation = AiReportResult.class) + schema = @Schema(implementation = AiReportResponse.class) ) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( @@ -290,7 +290,7 @@ ApiResponse createAndGradeBusinessPlan( ) }) @GetMapping("/{planId}") - ApiResponse getAiReport( + ApiResponse getAiReport( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId ); diff --git a/src/main/java/starlight/application/aireport/AiReportService.java b/src/main/java/starlight/application/aireport/AiReportService.java index f67411e..a91daf7 100644 --- a/src/main/java/starlight/application/aireport/AiReportService.java +++ b/src/main/java/starlight/application/aireport/AiReportService.java @@ -126,7 +126,7 @@ private String getRawJsonAiReportResponseFromGradingResult(AiReportResult gradin try { rawJsonString = objectMapper.writeValueAsString(gradingJsonNode); } catch (JsonProcessingException e) { - throw new RuntimeException("Failed to convert JsonNode to string", e); + throw new AiReportException("Failed to convert JsonNode to string", e); } return rawJsonString; } diff --git a/src/main/java/starlight/domain/aireport/exception/AiReportException.java b/src/main/java/starlight/domain/aireport/exception/AiReportException.java index 2b86898..afff430 100644 --- a/src/main/java/starlight/domain/aireport/exception/AiReportException.java +++ b/src/main/java/starlight/domain/aireport/exception/AiReportException.java @@ -8,4 +8,8 @@ public class AiReportException extends GlobalException { public AiReportException(ErrorType errorType) { super(errorType); } + + public AiReportException(String message, Throwable cause) { + super(message, cause, AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } } diff --git a/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java b/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java index 1c7a819..d1a386a 100644 --- a/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java +++ b/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java @@ -11,4 +11,9 @@ public GlobalException(ErrorType errorType) { super(errorType.getMessage()); this.errorType = errorType; } + + public GlobalException(String message, Throwable cause, ErrorType errorType) { + super(message, cause); + this.errorType = errorType; + } } \ No newline at end of file From 0148a6d0f6ec5ee77f7d6021a4b58f1a1b4ebd35 Mon Sep 17 00:00:00 2001 From: SungHo Jung <65662139+SeongHo5356@users.noreply.github.com> Date: Sun, 25 Jan 2026 15:42:34 +0900 Subject: [PATCH 57/74] Update GlobalException.java --- .../starlight/shared/apiPayload/exception/GlobalException.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java b/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java index 6ee6fa3..35e5c72 100644 --- a/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java +++ b/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java @@ -16,7 +16,7 @@ public GlobalException(String message, Throwable cause, ErrorType errorType) { super(message, cause); this.errorType = errorType; } -} + public GlobalException(ErrorType errorType, Throwable cause) { super(errorType.getMessage(), cause); this.errorType = errorType; From 095146bb8eaed3bef8e1d78d9071df6f468c0c76 Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Sun, 25 Jan 2026 20:51:46 -0600 Subject: [PATCH 58/74] =?UTF-8?q?[SRLT-124]=20Chore:=20=EC=84=B9=EC=85=98?= =?UTF-8?q?=20=EC=A0=90=EC=88=98=20=ED=8C=8C=EC=8B=B1=EC=9D=98=20null?= =?UTF-8?q?=EA=B3=BC=200=20=EA=B5=AC=EB=B6=84=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=9D=BC=EB=B6=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aireport/util/AiReportResponseParser.java | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/main/java/starlight/application/aireport/util/AiReportResponseParser.java b/src/main/java/starlight/application/aireport/util/AiReportResponseParser.java index 581ab3a..c03d8d5 100644 --- a/src/main/java/starlight/application/aireport/util/AiReportResponseParser.java +++ b/src/main/java/starlight/application/aireport/util/AiReportResponseParser.java @@ -213,10 +213,10 @@ public AiReportResult parseSectionResponse(String llmResponse) { // strengths와 weaknesses는 섹션별 응답에는 없음 return AiReportResult.fromGradingResult( - problemRecognitionScore != null ? problemRecognitionScore : 0, - feasibilityScore != null ? feasibilityScore : 0, - growthStrategyScore != null ? growthStrategyScore : 0, - teamCompetenceScore != null ? teamCompetenceScore : 0, + problemRecognitionScore, + feasibilityScore, + growthStrategyScore, + teamCompetenceScore, sectionScores, List.of(), // strengths는 빈 리스트 List.of() // weaknesses는 빈 리스트 @@ -367,10 +367,23 @@ private String repairIncompleteJson(String json) { * JsonNode를 파싱하여 AiReportResponse로 변환 */ private AiReportResult parseFromJsonNode(JsonNode jsonNode) { - Integer problemRecognitionScore = jsonNode.path("problemRecognitionScore").asInt(0); - Integer feasibilityScore = jsonNode.path("feasibilityScore").asInt(0); - Integer growthStrategyScore = jsonNode.path("growthStrategyScore").asInt(0); - Integer teamCompetenceScore = jsonNode.path("teamCompetenceScore").asInt(0); + 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")); From f37e2c6ac8632e1c046ecefbe81150709a61c7ad Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Sun, 25 Jan 2026 20:53:10 -0600 Subject: [PATCH 59/74] =?UTF-8?q?[SRLT-124]=20Chore:=20AiReportResponse=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=20=EC=8B=9C=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=EC=9D=98=20null=20=EC=95=88=EC=A0=95?= =?UTF-8?q?=EC=84=B1=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aireport/webapi/dto/AiReportResponse.java | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportResponse.java b/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportResponse.java index 485f488..dd573b0 100644 --- a/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportResponse.java +++ b/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportResponse.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonRawValue; import java.util.List; +import java.util.Objects; import starlight.application.aireport.provided.dto.AiReportResult; /** @@ -31,13 +32,21 @@ public record StrengthWeakness( ) {} public static AiReportResponse from(AiReportResult result) { - List sectionScores = result.sectionScores().stream() + List sourceSectionScores = + Objects.requireNonNullElse(result.sectionScores(), List.of()); + List sectionScores = sourceSectionScores.stream() .map(s -> new SectionScoreDetailResponse(s.sectionType(), s.gradingListScores())) .toList(); - List strengths = result.strengths().stream() + + List sourceStrengths = + Objects.requireNonNullElse(result.strengths(), List.of()); + List strengths = sourceStrengths.stream() .map(s -> new StrengthWeakness(s.title(), s.content())) .toList(); - List weaknesses = result.weaknesses().stream() + + List sourceWeaknesses = + Objects.requireNonNullElse(result.weaknesses(), List.of()); + List weaknesses = sourceWeaknesses.stream() .map(w -> new StrengthWeakness(w.title(), w.content())) .toList(); From 6537f844186f64392083c0ab6002a49c88a7be3d Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Sun, 25 Jan 2026 20:59:38 -0600 Subject: [PATCH 60/74] =?UTF-8?q?[SRLT-124]=20Chore:=20GlobalException=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EB=A1=A4=EB=B0=B1=20=ED=9B=84=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20=EC=97=90=EB=9F=AC=20=EB=A9=94=EC=BB=A4=EB=8B=88?= =?UTF-8?q?=EC=A6=98=EC=97=90=20=EC=9D=BC=EA=B4=80=EB=90=98=EB=8F=84?= =?UTF-8?q?=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 --- .../java/starlight/application/aireport/AiReportService.java | 2 +- .../domain/aireport/exception/AiReportException.java | 4 ---- .../shared/apiPayload/exception/GlobalException.java | 5 ----- 3 files changed, 1 insertion(+), 10 deletions(-) diff --git a/src/main/java/starlight/application/aireport/AiReportService.java b/src/main/java/starlight/application/aireport/AiReportService.java index a91daf7..3ad5e4c 100644 --- a/src/main/java/starlight/application/aireport/AiReportService.java +++ b/src/main/java/starlight/application/aireport/AiReportService.java @@ -126,7 +126,7 @@ private String getRawJsonAiReportResponseFromGradingResult(AiReportResult gradin try { rawJsonString = objectMapper.writeValueAsString(gradingJsonNode); } catch (JsonProcessingException e) { - throw new AiReportException("Failed to convert JsonNode to string", e); + throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); } return rawJsonString; } diff --git a/src/main/java/starlight/domain/aireport/exception/AiReportException.java b/src/main/java/starlight/domain/aireport/exception/AiReportException.java index afff430..2b86898 100644 --- a/src/main/java/starlight/domain/aireport/exception/AiReportException.java +++ b/src/main/java/starlight/domain/aireport/exception/AiReportException.java @@ -8,8 +8,4 @@ public class AiReportException extends GlobalException { public AiReportException(ErrorType errorType) { super(errorType); } - - public AiReportException(String message, Throwable cause) { - super(message, cause, AiReportErrorType.AI_RESPONSE_PARSING_FAILED); - } } diff --git a/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java b/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java index d1a386a..1c7a819 100644 --- a/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java +++ b/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java @@ -11,9 +11,4 @@ public GlobalException(ErrorType errorType) { super(errorType.getMessage()); this.errorType = errorType; } - - public GlobalException(String message, Throwable cause, ErrorType errorType) { - super(message, cause); - this.errorType = errorType; - } } \ No newline at end of file From 308b46156ad8d2f78b0c11f1959f35278113be2e Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Sun, 25 Jan 2026 21:19:17 -0600 Subject: [PATCH 61/74] =?UTF-8?q?[SRLT-124]=20Refactor:=20pdf=20URL=20http?= =?UTF-8?q?s=EB=A1=9C=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aireport/webapi/dto/AiReportCreateWithPdfRequest.java | 3 +++ 1 file changed, 3 insertions(+) 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 87ea1c2..854c658 100644 --- a/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportCreateWithPdfRequest.java +++ b/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportCreateWithPdfRequest.java @@ -1,11 +1,14 @@ package starlight.adapter.aireport.webapi.dto; import jakarta.validation.constraints.NotBlank; +import org.hibernate.validator.constraints.URL; public record AiReportCreateWithPdfRequest( @NotBlank(message = "제목은 필수입니다.") String title, + // TODO: 버킷 정책 등에 따라서 이후에 host 강제할 것 @NotBlank(message = "PDF URL은 필수입니다.") + @URL(protocol = "https", message = "https URL만 허용됩니다.") String pdfUrl ) {} From 6a37fed585c7178c3c550eef3e10d8a6511e5d27 Mon Sep 17 00:00:00 2001 From: 2ghrms Date: Sun, 25 Jan 2026 21:22:35 -0600 Subject: [PATCH 62/74] =?UTF-8?q?[SRLT-124]=20Chore:=20=ED=99=95=EC=9E=A5?= =?UTF-8?q?=EB=90=9C=20Global=20Exception=EC=97=90=20=EB=94=B0=EB=9D=BC?= =?UTF-8?q?=EC=84=9C=20AiReportException=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/starlight/application/aireport/AiReportService.java | 2 +- .../domain/aireport/exception/AiReportException.java | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/starlight/application/aireport/AiReportService.java b/src/main/java/starlight/application/aireport/AiReportService.java index 3ad5e4c..f7b73f5 100644 --- a/src/main/java/starlight/application/aireport/AiReportService.java +++ b/src/main/java/starlight/application/aireport/AiReportService.java @@ -126,7 +126,7 @@ private String getRawJsonAiReportResponseFromGradingResult(AiReportResult gradin try { rawJsonString = objectMapper.writeValueAsString(gradingJsonNode); } catch (JsonProcessingException e) { - throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED, e); } return rawJsonString; } diff --git a/src/main/java/starlight/domain/aireport/exception/AiReportException.java b/src/main/java/starlight/domain/aireport/exception/AiReportException.java index 2b86898..d593391 100644 --- a/src/main/java/starlight/domain/aireport/exception/AiReportException.java +++ b/src/main/java/starlight/domain/aireport/exception/AiReportException.java @@ -8,4 +8,8 @@ public class AiReportException extends GlobalException { public AiReportException(ErrorType errorType) { super(errorType); } + + public AiReportException(ErrorType errorType, Throwable cause) { + super(errorType, cause); + } } From 1bca1e4bed13847fe0ca3309e4f6fe453703c2d6 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Fri, 23 Jan 2026 14:26:06 +0900 Subject: [PATCH 63/74] =?UTF-8?q?[SRLT-133]=20Feat:=20=EC=A0=84=EB=AC=B8?= =?UTF-8?q?=EA=B0=80=20=ED=99=9C=EC=84=B1=EC=83=81=ED=83=9C=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20=EC=B6=94=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../starlight/domain/expert/entity/Expert.java | 5 +++++ .../expert/enumerate/ExpertActiveStatus.java | 14 ++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 src/main/java/starlight/domain/expert/enumerate/ExpertActiveStatus.java diff --git a/src/main/java/starlight/domain/expert/entity/Expert.java b/src/main/java/starlight/domain/expert/entity/Expert.java index f1ce0af..8ef04d3 100644 --- a/src/main/java/starlight/domain/expert/entity/Expert.java +++ b/src/main/java/starlight/domain/expert/entity/Expert.java @@ -5,6 +5,7 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import starlight.domain.expert.enumerate.ExpertActiveStatus; import starlight.domain.expert.enumerate.TagCategory; import starlight.shared.AbstractEntity; @@ -36,6 +37,10 @@ public class Expert extends AbstractEntity { @Column private String detailedIntroduction; + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private ExpertActiveStatus activeStatus = ExpertActiveStatus.ACTIVE; + @Min(0) @Column private Integer mentoringPriceWon; diff --git a/src/main/java/starlight/domain/expert/enumerate/ExpertActiveStatus.java b/src/main/java/starlight/domain/expert/enumerate/ExpertActiveStatus.java new file mode 100644 index 0000000..198209f --- /dev/null +++ b/src/main/java/starlight/domain/expert/enumerate/ExpertActiveStatus.java @@ -0,0 +1,14 @@ +package starlight.domain.expert.enumerate; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ExpertActiveStatus { + + ACTIVE("활동중"), + INACTIVE("비활동중"); + + private final String description; +} From 87228f719d6c762d92913edecedbc2bc99ac83f2 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Fri, 23 Jan 2026 17:00:52 +0900 Subject: [PATCH 64/74] =?UTF-8?q?[SRLT-133]=20Feat:=20=EB=B0=B1=EC=98=A4?= =?UTF-8?q?=ED=94=BC=EC=8A=A4=EC=9A=A9=20=EC=A0=84=EB=AC=B8=EA=B0=80?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java --- .../webapi/BackofficeExpertController.java | 26 ++++++++ .../BackofficeExpertListResponse.java | 59 +++++++++++++++++++ .../mail/webapi/BackofficeMailController.java | 2 + .../adapter/expert/persistence/ExpertJpa.java | 1 + .../expert/webapi/ExpertController.java | 2 +- .../persistence/ExpertApplicationJpa.java | 3 +- .../expert/BackofficeExpertQueryService.java | 37 ++++++++++++ .../BackofficeExpertQueryUseCase.java | 10 ++++ .../result/BackofficeExpertDetailResult.java | 55 +++++++++++++++++ ...fficeExpertApplicationCountLookupPort.java | 9 +++ .../required/BackofficeExpertQueryPort.java | 10 ++++ .../expert/ExpertDetailQueryService.java | 16 ++++- .../provided/ExpertDetailQueryUseCase.java | 2 +- .../starlight/bootstrap/SecurityConfig.java | 2 +- .../starlight/bootstrap/SwaggerConfig.java | 15 ++--- .../expert/webapi/ExpertControllerTest.java | 2 +- 16 files changed, 236 insertions(+), 15 deletions(-) create mode 100644 src/main/java/starlight/adapter/backoffice/expert/webapi/BackofficeExpertController.java create mode 100644 src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertListResponse.java create mode 100644 src/main/java/starlight/application/backoffice/expert/BackofficeExpertQueryService.java create mode 100644 src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertQueryUseCase.java create mode 100644 src/main/java/starlight/application/backoffice/expert/provided/dto/result/BackofficeExpertDetailResult.java create mode 100644 src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertApplicationCountLookupPort.java create mode 100644 src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertQueryPort.java diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/BackofficeExpertController.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/BackofficeExpertController.java new file mode 100644 index 0000000..9ea121d --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/BackofficeExpertController.java @@ -0,0 +1,26 @@ +package starlight.adapter.backoffice.expert.webapi; + +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import starlight.adapter.backoffice.expert.webapi.dto.response.BackofficeExpertListResponse; +import starlight.application.backoffice.expert.provided.BackofficeExpertQueryUseCase; +import starlight.shared.apiPayload.response.ApiResponse; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@SecurityRequirement(name = "backofficeSession") +@RequestMapping("/v1/backoffice/experts") +public class BackofficeExpertController { + + private final BackofficeExpertQueryUseCase backofficeExpertQuery; + + @GetMapping + public ApiResponse> searchAll() { + return ApiResponse.success(BackofficeExpertListResponse.fromAll(backofficeExpertQuery.searchAll())); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertListResponse.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertListResponse.java new file mode 100644 index 0000000..b9e3a18 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertListResponse.java @@ -0,0 +1,59 @@ +package starlight.adapter.backoffice.expert.webapi.dto.response; + +import starlight.application.backoffice.expert.provided.dto.result.BackofficeExpertDetailResult; +import starlight.application.expert.provided.dto.ExpertCareerResult; + +import java.util.List; + +public record BackofficeExpertListResponse( + Long id, + String name, + String oneLineIntroduction, + String profileImageUrl, + Long workedPeriod, + String email, + String activeStatus, + List careers, + List tags, + List categories +) { + private static final int MAX_CAREERS = 3; + + public static BackofficeExpertListResponse from(BackofficeExpertDetailResult result) { + List careers = result.careers().stream() + .limit(MAX_CAREERS) + .map(BackofficeExpertCareerSummaryResponse::from) + .toList(); + + return new BackofficeExpertListResponse( + result.id(), + result.name(), + result.oneLineIntroduction(), + result.profileImageUrl(), + result.workedPeriod(), + result.email(), + result.activeStatus().name(), + careers, + result.tags(), + result.categories() + ); + } + + public static List fromAll(List results) { + return results.stream() + .map(BackofficeExpertListResponse::from) + .toList(); + } + + public record BackofficeExpertCareerSummaryResponse( + Integer orderIndex, + String careerTitle + ) { + public static BackofficeExpertCareerSummaryResponse from(ExpertCareerResult result) { + return new BackofficeExpertCareerSummaryResponse( + result.orderIndex(), + result.careerTitle() + ); + } + } +} diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java index f649253..fc4deee 100644 --- a/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java @@ -2,6 +2,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import org.springframework.web.bind.annotation.*; import starlight.adapter.backoffice.mail.webapi.dto.request.BackofficeMailSendRequest; import starlight.adapter.backoffice.mail.webapi.dto.request.BackofficeMailTemplateCreateRequest; @@ -14,6 +15,7 @@ @RestController @RequiredArgsConstructor +@SecurityRequirement(name = "backofficeSession") public class BackofficeMailController { private final BackofficeMailSendUseCase backofficeMailSendUseCase; diff --git a/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java b/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java index 9250181..deae9a0 100644 --- a/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java +++ b/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java @@ -18,6 +18,7 @@ @Component @RequiredArgsConstructor public class ExpertJpa implements ExpertQueryPort, + starlight.application.backoffice.expert.required.BackofficeExpertQueryPort, starlight.application.expertReport.required.ExpertLookupPort, starlight.application.expertApplication.required.ExpertLookupPort { diff --git a/src/main/java/starlight/adapter/expert/webapi/ExpertController.java b/src/main/java/starlight/adapter/expert/webapi/ExpertController.java index ce98ccd..c92942a 100644 --- a/src/main/java/starlight/adapter/expert/webapi/ExpertController.java +++ b/src/main/java/starlight/adapter/expert/webapi/ExpertController.java @@ -27,7 +27,7 @@ public class ExpertController implements ExpertApiDoc { @GetMapping public ApiResponse> search() { - return ApiResponse.success(ExpertListResponse.fromAll(expertDetailQuery.searchAll())); + return ApiResponse.success(ExpertListResponse.fromAll(expertDetailQuery.searchAllActive())); } @GetMapping("/{expertId}") diff --git a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java index b8a4ef3..0f974f1 100644 --- a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java +++ b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java @@ -16,7 +16,8 @@ @Slf4j @Component @RequiredArgsConstructor -public class ExpertApplicationJpa implements ExpertApplicationQueryPort, +public class ExpertApplicationJpaPort implements ExpertApplicationQueryPort, + starlight.application.backoffice.expert.required.BackofficeExpertApplicationCountLookupPort, starlight.application.expert.required.ExpertApplicationCountLookupPort, starlight.application.expertReport.required.ExpertApplicationCountLookupPort { diff --git a/src/main/java/starlight/application/backoffice/expert/BackofficeExpertQueryService.java b/src/main/java/starlight/application/backoffice/expert/BackofficeExpertQueryService.java new file mode 100644 index 0000000..9d7f640 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/BackofficeExpertQueryService.java @@ -0,0 +1,37 @@ +package starlight.application.backoffice.expert; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import starlight.application.backoffice.expert.provided.BackofficeExpertQueryUseCase; +import starlight.application.backoffice.expert.provided.dto.result.BackofficeExpertDetailResult; +import starlight.application.backoffice.expert.required.BackofficeExpertApplicationCountLookupPort; +import starlight.application.backoffice.expert.required.BackofficeExpertQueryPort; +import starlight.domain.expert.entity.Expert; + +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BackofficeExpertQueryService implements BackofficeExpertQueryUseCase { + + private final BackofficeExpertQueryPort expertQueryPort; + private final BackofficeExpertApplicationCountLookupPort expertApplicationLookupPort; + + @Override + public List searchAll() { + List experts = expertQueryPort.findAllWithCareersTagsCategories(); + + List expertIds = experts.stream() + .map(Expert::getId) + .toList(); + + Map countMap = expertApplicationLookupPort.countByExpertIds(expertIds); + + return experts.stream() + .map(expert -> BackofficeExpertDetailResult.from(expert, countMap.getOrDefault(expert.getId(), 0L))) + .toList(); + } +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertQueryUseCase.java b/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertQueryUseCase.java new file mode 100644 index 0000000..97b4a7c --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertQueryUseCase.java @@ -0,0 +1,10 @@ +package starlight.application.backoffice.expert.provided; + +import starlight.application.backoffice.expert.provided.dto.result.BackofficeExpertDetailResult; + +import java.util.List; + +public interface BackofficeExpertQueryUseCase { + + List searchAll(); +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/dto/result/BackofficeExpertDetailResult.java b/src/main/java/starlight/application/backoffice/expert/provided/dto/result/BackofficeExpertDetailResult.java new file mode 100644 index 0000000..ae1cb97 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/dto/result/BackofficeExpertDetailResult.java @@ -0,0 +1,55 @@ +package starlight.application.backoffice.expert.provided.dto.result; + +import starlight.application.expert.provided.dto.ExpertCareerResult; +import starlight.domain.expert.entity.Expert; +import starlight.domain.expert.enumerate.ExpertActiveStatus; +import starlight.domain.expert.enumerate.TagCategory; + +import java.util.List; + +public record BackofficeExpertDetailResult( + Long id, + Long applicationCount, + String name, + String oneLineIntroduction, + String detailedIntroduction, + String profileImageUrl, + Long workedPeriod, + String email, + Integer mentoringPriceWon, + ExpertActiveStatus activeStatus, + List careers, + List tags, + List categories +) { + public static BackofficeExpertDetailResult from(Expert expert, long applicationCount) { + List careers = expert.getCareers().stream() + .map(ExpertCareerResult::from) + .toList(); + + List categories = expert.getCategories().stream() + .map(TagCategory::name) + .distinct() + .toList(); + + List tags = expert.getTags().stream() + .distinct() + .toList(); + + return new BackofficeExpertDetailResult( + expert.getId(), + applicationCount, + expert.getName(), + expert.getOneLineIntroduction(), + expert.getDetailedIntroduction(), + expert.getProfileImageUrl(), + expert.getWorkedPeriod(), + expert.getEmail(), + expert.getMentoringPriceWon(), + expert.getActiveStatus(), + careers, + tags, + categories + ); + } +} diff --git a/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertApplicationCountLookupPort.java b/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertApplicationCountLookupPort.java new file mode 100644 index 0000000..3bfd730 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertApplicationCountLookupPort.java @@ -0,0 +1,9 @@ +package starlight.application.backoffice.expert.required; + +import java.util.List; +import java.util.Map; + +public interface BackofficeExpertApplicationCountLookupPort { + + Map countByExpertIds(List expertIds); +} diff --git a/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertQueryPort.java b/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertQueryPort.java new file mode 100644 index 0000000..381519b --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertQueryPort.java @@ -0,0 +1,10 @@ +package starlight.application.backoffice.expert.required; + +import starlight.domain.expert.entity.Expert; + +import java.util.List; + +public interface BackofficeExpertQueryPort { + + List findAllWithCareersTagsCategories(); +} diff --git a/src/main/java/starlight/application/expert/ExpertDetailQueryService.java b/src/main/java/starlight/application/expert/ExpertDetailQueryService.java index 88edcea..1625e4d 100644 --- a/src/main/java/starlight/application/expert/ExpertDetailQueryService.java +++ b/src/main/java/starlight/application/expert/ExpertDetailQueryService.java @@ -7,7 +7,10 @@ import starlight.application.expert.provided.dto.ExpertDetailResult; import starlight.application.expert.required.ExpertApplicationCountLookupPort; import starlight.application.expert.required.ExpertQueryPort; +import starlight.domain.expert.enumerate.ExpertActiveStatus; import starlight.domain.expert.entity.Expert; +import starlight.domain.expert.exception.ExpertErrorType; +import starlight.domain.expert.exception.ExpertException; import java.util.List; import java.util.Map; @@ -21,16 +24,20 @@ public class ExpertDetailQueryService implements ExpertDetailQueryUseCase { private final ExpertApplicationCountLookupPort expertApplicationLookupPort; @Override - public List searchAll() { + public List searchAllActive() { List experts = expertQueryPort.findAllWithCareersTagsCategories(); - List expertIds = experts.stream() + List activeExperts = experts.stream() + .filter(expert -> expert.getActiveStatus() == ExpertActiveStatus.ACTIVE) + .toList(); + + List expertIds = activeExperts.stream() .map(Expert::getId) .toList(); Map countMap = expertApplicationLookupPort.countByExpertIds(expertIds); - return experts.stream() + return activeExperts.stream() .map(expert -> ExpertDetailResult.from(expert, countMap.getOrDefault(expert.getId(), 0L))) .toList(); } @@ -38,6 +45,9 @@ public List searchAll() { @Override public ExpertDetailResult findById(Long expertId) { Expert expert = expertQueryPort.findByIdWithCareersAndTags(expertId); + if (expert.getActiveStatus() != ExpertActiveStatus.ACTIVE) { + throw new ExpertException(ExpertErrorType.EXPERT_NOT_FOUND); + } Map countMap = expertApplicationLookupPort.countByExpertIds(List.of(expertId)); long count = countMap.getOrDefault(expertId, 0L); return ExpertDetailResult.from(expert, count); diff --git a/src/main/java/starlight/application/expert/provided/ExpertDetailQueryUseCase.java b/src/main/java/starlight/application/expert/provided/ExpertDetailQueryUseCase.java index 705b16e..53c13d4 100644 --- a/src/main/java/starlight/application/expert/provided/ExpertDetailQueryUseCase.java +++ b/src/main/java/starlight/application/expert/provided/ExpertDetailQueryUseCase.java @@ -6,7 +6,7 @@ public interface ExpertDetailQueryUseCase { - List searchAll(); + List searchAllActive(); ExpertDetailResult findById(Long expertId); } diff --git a/src/main/java/starlight/bootstrap/SecurityConfig.java b/src/main/java/starlight/bootstrap/SecurityConfig.java index 07f4232..055e7ad 100644 --- a/src/main/java/starlight/bootstrap/SecurityConfig.java +++ b/src/main/java/starlight/bootstrap/SecurityConfig.java @@ -76,7 +76,7 @@ public SecurityFilterChain backofficeFilterChain(HttpSecurity http) throws Excep ); } - http.securityMatcher("/v1/backoffice/mail/**", "/login", "/logout") + http.securityMatcher("/v1/backoffice/**", "/login", "/logout") .cors(Customizer.withDefaults()) .csrf((csrf) -> csrf .csrfTokenRepository(csrfTokenRepository) diff --git a/src/main/java/starlight/bootstrap/SwaggerConfig.java b/src/main/java/starlight/bootstrap/SwaggerConfig.java index df6ebb1..879c6f2 100644 --- a/src/main/java/starlight/bootstrap/SwaggerConfig.java +++ b/src/main/java/starlight/bootstrap/SwaggerConfig.java @@ -2,7 +2,6 @@ import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.info.Info; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.servers.Server; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; @@ -10,8 +9,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import java.util.Collections; - @OpenAPIDefinition( info = @Info(title = "StarLight 명세서", description = "StarLight API 명세서", version = "v1" ), @@ -31,11 +28,15 @@ public OpenAPI openAPI() { .bearerFormat("JWT") .in(SecurityScheme.In.HEADER) .name("Authorization"); - io.swagger.v3.oas.models.security.SecurityRequirement securityRequirement = - new io.swagger.v3.oas.models.security.SecurityRequirement().addList("bearerAuth"); + SecurityScheme backofficeSessionScheme = new SecurityScheme() + .type(SecurityScheme.Type.APIKEY) + .in(SecurityScheme.In.COOKIE) + .name("JSESSIONID"); return new OpenAPI() - .components(new Components().addSecuritySchemes("bearerAuth", securityScheme)) - .security(Collections.singletonList(securityRequirement)); + .components(new Components() + .addSecuritySchemes("bearerAuth", securityScheme) + .addSecuritySchemes("backofficeSession", backofficeSessionScheme) + ); } } diff --git a/src/test/java/starlight/adapter/expert/webapi/ExpertControllerTest.java b/src/test/java/starlight/adapter/expert/webapi/ExpertControllerTest.java index a4c7a49..42d69a8 100644 --- a/src/test/java/starlight/adapter/expert/webapi/ExpertControllerTest.java +++ b/src/test/java/starlight/adapter/expert/webapi/ExpertControllerTest.java @@ -64,7 +64,7 @@ class ExpertControllerTest { void listAll() throws Exception { ExpertDetailResult e1 = expertResult(1L, "홍길동", Set.of(TagCategory.GROWTH_STRATEGY, TagCategory.TEAM_CAPABILITY)); - when(expertDetailQuery.searchAll()).thenReturn(List.of(e1)); + when(expertDetailQuery.searchAllActive()).thenReturn(List.of(e1)); mockMvc.perform(get("/v1/experts")) .andExpect(status().isOk()) From bb5dc46d80db5ef91bfa6ced17ddf3440a59f970 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Sun, 25 Jan 2026 14:43:54 +0900 Subject: [PATCH 65/74] =?UTF-8?q?[SRLT-133]=20Feat:=20=EB=B0=B1=EC=98=A4?= =?UTF-8?q?=ED=94=BC=EC=8A=A4=20=EC=A0=84=EB=AC=B8=EA=B0=80/=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80/=EB=A9=94=EC=9D=BC=20=EA=B4=80=EB=A0=A8=20AP?= =?UTF-8?q?I=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전문가 관리 API: 생성, 조회, 수정, 삭제, 활성 상태 변경 및 Swagger 문서 추가 - 이미지 업로드 API: Presigned URL 발급, 이미지 공개 전환 요청 및 Swagger 문서 추가 - 메일 관리 API: 템플릿 생성, 조회, 발송 및 Swagger 문서 추가 - DTO, validation 어노테이션, 요청/응답 매핑 구현 - API 문서화 및 요청-응답 형식 정의 --- .../storage/NcpPresignedUrlProvider.java | 2 + .../webapi/BackofficeExpertController.java | 82 +++++- ...officeExpertActiveStatusUpdateRequest.java | 8 + .../BackofficeExpertCareerUpdateRequest.java | 25 ++ .../BackofficeExpertCreateRequest.java | 26 ++ ...officeExpertProfileImageUpdateRequest.java | 7 + .../BackofficeExpertUpdateRequest.java | 50 ++++ .../BackofficeExpertCareerResponse.java | 25 ++ .../BackofficeExpertCreateResponse.java | 11 + .../BackofficeExpertDetailResponse.java | 43 +++ .../swagger/BackofficeExpertApiDoc.java | 269 ++++++++++++++++++ .../webapi/BackofficeImageController.java | 45 +++ .../request/BackofficeImagePublicRequest.java | 7 + .../webapi/swagger/BackofficeImageApiDoc.java | 86 ++++++ .../webapi/validation/ValidImageFileName.java | 23 ++ .../ValidImageFileNameValidator.java | 27 ++ .../mail/webapi/BackofficeMailController.java | 5 +- .../webapi/swagger/BackofficeMailApiDoc.java | 135 +++++++++ .../adapter/expert/persistence/ExpertJpa.java | 27 ++ .../BackofficeExpertCommandService.java | 99 +++++++ .../expert/BackofficeExpertQueryService.java | 17 +- .../BackofficeExpertCommandUseCase.java | 20 ++ .../BackofficeExpertQueryUseCase.java | 2 + ...ckofficeExpertActiveStatusUpdateInput.java | 12 + .../BackofficeExpertCareerUpdateInput.java | 13 + .../input/BackofficeExpertCreateInput.java | 23 ++ ...ckofficeExpertProfileImageUpdateInput.java | 10 + .../input/BackofficeExpertUpdateInput.java | 44 +++ .../result/BackofficeExpertCreateResult.java | 9 + .../required/BackofficeExpertCommandPort.java | 10 + .../required/BackofficeExpertQueryPort.java | 4 + .../domain/expert/dto/ExpertCareerUpdate.java | 12 + .../domain/expert/entity/Expert.java | 156 ++++++++++ .../domain/expert/entity/ExpertCareer.java | 3 +- .../expert/exception/ExpertErrorType.java | 5 +- ...34\352\260\200\354\235\264\353\223\234.md" | 16 ++ 36 files changed, 1350 insertions(+), 8 deletions(-) create mode 100644 src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertActiveStatusUpdateRequest.java create mode 100644 src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertCareerUpdateRequest.java create mode 100644 src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertCreateRequest.java create mode 100644 src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertProfileImageUpdateRequest.java create mode 100644 src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertUpdateRequest.java create mode 100644 src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertCareerResponse.java create mode 100644 src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertCreateResponse.java create mode 100644 src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertDetailResponse.java create mode 100644 src/main/java/starlight/adapter/backoffice/expert/webapi/swagger/BackofficeExpertApiDoc.java create mode 100644 src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java create mode 100644 src/main/java/starlight/adapter/backoffice/image/webapi/dto/request/BackofficeImagePublicRequest.java create mode 100644 src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java create mode 100644 src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileName.java create mode 100644 src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileNameValidator.java create mode 100644 src/main/java/starlight/adapter/backoffice/mail/webapi/swagger/BackofficeMailApiDoc.java create mode 100644 src/main/java/starlight/application/backoffice/expert/BackofficeExpertCommandService.java create mode 100644 src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertCommandUseCase.java create mode 100644 src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertActiveStatusUpdateInput.java create mode 100644 src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertCareerUpdateInput.java create mode 100644 src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertCreateInput.java create mode 100644 src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertProfileImageUpdateInput.java create mode 100644 src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertUpdateInput.java create mode 100644 src/main/java/starlight/application/backoffice/expert/provided/dto/result/BackofficeExpertCreateResult.java create mode 100644 src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertCommandPort.java create mode 100644 src/main/java/starlight/domain/expert/dto/ExpertCareerUpdate.java diff --git a/src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java b/src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java index 59ffb38..188211a 100644 --- a/src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java +++ b/src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java @@ -79,12 +79,14 @@ public String makePublic(String objectUrl) { .key(key) .acl(ObjectCannedACL.PUBLIC_READ) .build(); + ncpS3Client.putObjectAcl(aclRequest); log.info("객체 공개 처리 완료(PUBLIC_READ): key={}", objectUrl); } catch (S3Exception e) { log.error("객체 공개 처리 실패 - Message: {}", e.getMessage()); throw new RuntimeException("객체 공개 처리 실패: " + e.getMessage(), e); } + return objectUrl; } diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/BackofficeExpertController.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/BackofficeExpertController.java index 9ea121d..da233e1 100644 --- a/src/main/java/starlight/adapter/backoffice/expert/webapi/BackofficeExpertController.java +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/BackofficeExpertController.java @@ -1,12 +1,28 @@ package starlight.adapter.backoffice.expert.webapi; import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import starlight.adapter.backoffice.expert.webapi.dto.request.BackofficeExpertActiveStatusUpdateRequest; +import starlight.adapter.backoffice.expert.webapi.dto.request.BackofficeExpertCreateRequest; +import starlight.adapter.backoffice.expert.webapi.dto.request.BackofficeExpertProfileImageUpdateRequest; +import starlight.adapter.backoffice.expert.webapi.dto.request.BackofficeExpertUpdateRequest; +import starlight.adapter.backoffice.expert.webapi.dto.response.BackofficeExpertCreateResponse; +import starlight.adapter.backoffice.expert.webapi.dto.response.BackofficeExpertDetailResponse; import starlight.adapter.backoffice.expert.webapi.dto.response.BackofficeExpertListResponse; +import starlight.adapter.backoffice.expert.webapi.swagger.BackofficeExpertApiDoc; +import starlight.application.backoffice.expert.provided.BackofficeExpertCommandUseCase; import starlight.application.backoffice.expert.provided.BackofficeExpertQueryUseCase; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertActiveStatusUpdateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertProfileImageUpdateInput; import starlight.shared.apiPayload.response.ApiResponse; import java.util.List; @@ -15,12 +31,74 @@ @RequiredArgsConstructor @SecurityRequirement(name = "backofficeSession") @RequestMapping("/v1/backoffice/experts") -public class BackofficeExpertController { +public class BackofficeExpertController implements BackofficeExpertApiDoc { private final BackofficeExpertQueryUseCase backofficeExpertQuery; + private final BackofficeExpertCommandUseCase backofficeExpertCommand; @GetMapping public ApiResponse> searchAll() { - return ApiResponse.success(BackofficeExpertListResponse.fromAll(backofficeExpertQuery.searchAll())); + return ApiResponse.success(BackofficeExpertListResponse.fromAll( + backofficeExpertQuery.searchAll() + )); } + + @PostMapping + public ApiResponse create( + @Valid @RequestBody BackofficeExpertCreateRequest request + ) { + return ApiResponse.success(BackofficeExpertCreateResponse.from( + backofficeExpertCommand.createExpert(request.toInput()) + )); + } + + @GetMapping("/{expertId}") + public ApiResponse detail( + @PathVariable Long expertId + ) { + return ApiResponse.success(BackofficeExpertDetailResponse.from( + backofficeExpertQuery.findById(expertId) + )); + } + + @PatchMapping("/{expertId}/active-status") + public ApiResponse updateActiveStatus( + @PathVariable Long expertId, + @Valid @RequestBody BackofficeExpertActiveStatusUpdateRequest request + ) { + backofficeExpertCommand.updateActiveStatus( + BackofficeExpertActiveStatusUpdateInput.of(expertId, request.activeStatus()) + ); + + return ApiResponse.success(); + } + + @PatchMapping("/{expertId}") + public ApiResponse update( + @PathVariable Long expertId, + @Valid @RequestBody BackofficeExpertUpdateRequest request + ) { + backofficeExpertCommand.updateExpert(request.toInput(expertId)); + return ApiResponse.success(); + } + + @DeleteMapping("/{expertId}") + public ApiResponse delete( + @PathVariable Long expertId + ) { + backofficeExpertCommand.deleteExpert(expertId); + return ApiResponse.success(); + } + + @PatchMapping("/{expertId}/profile-image") + public ApiResponse updateProfileImage( + @PathVariable Long expertId, + @Valid @RequestBody BackofficeExpertProfileImageUpdateRequest request + ) { + backofficeExpertCommand.updateProfileImage( + BackofficeExpertProfileImageUpdateInput.of(expertId, request.profileImageUrl()) + ); + return ApiResponse.success(); + } + } diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertActiveStatusUpdateRequest.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertActiveStatusUpdateRequest.java new file mode 100644 index 0000000..8751850 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertActiveStatusUpdateRequest.java @@ -0,0 +1,8 @@ +package starlight.adapter.backoffice.expert.webapi.dto.request; + +import jakarta.validation.constraints.NotNull; +import starlight.domain.expert.enumerate.ExpertActiveStatus; + +public record BackofficeExpertActiveStatusUpdateRequest( + @NotNull ExpertActiveStatus activeStatus +) { } diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertCareerUpdateRequest.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertCareerUpdateRequest.java new file mode 100644 index 0000000..15d1c11 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertCareerUpdateRequest.java @@ -0,0 +1,25 @@ +package starlight.adapter.backoffice.expert.webapi.dto.request; + +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.LocalDateTime; + +public record BackofficeExpertCareerUpdateRequest( + Long id, + @NotNull @Min(0) Integer orderIndex, + @NotBlank String careerTitle, + String careerExplanation, + @NotNull LocalDateTime careerStartedAt, + @NotNull LocalDateTime careerEndedAt +) { + @AssertTrue(message = "경력 시작일은 종료일보다 늦을 수 없습니다.") + public boolean isValidPeriod() { + if (careerStartedAt == null || careerEndedAt == null) { + return true; + } + return !careerStartedAt.isAfter(careerEndedAt); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertCreateRequest.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertCreateRequest.java new file mode 100644 index 0000000..90619cf --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertCreateRequest.java @@ -0,0 +1,26 @@ +package starlight.adapter.backoffice.expert.webapi.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertCreateInput; +import starlight.domain.expert.enumerate.TagCategory; + +import java.util.List; + +public record BackofficeExpertCreateRequest( + @NotBlank String name, + @Email @NotBlank String email, + String oneLineIntroduction, + List tags, + List categories +) { + public BackofficeExpertCreateInput toInput() { + return BackofficeExpertCreateInput.of( + name, + email, + oneLineIntroduction, + tags, + categories + ); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertProfileImageUpdateRequest.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertProfileImageUpdateRequest.java new file mode 100644 index 0000000..5cfdce3 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertProfileImageUpdateRequest.java @@ -0,0 +1,7 @@ +package starlight.adapter.backoffice.expert.webapi.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record BackofficeExpertProfileImageUpdateRequest( + @NotBlank String profileImageUrl +) { } diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertUpdateRequest.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertUpdateRequest.java new file mode 100644 index 0000000..740952f --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertUpdateRequest.java @@ -0,0 +1,50 @@ +package starlight.adapter.backoffice.expert.webapi.dto.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertCareerUpdateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertUpdateInput; +import starlight.domain.expert.enumerate.TagCategory; + +import java.util.List; + +public record BackofficeExpertUpdateRequest( + @NotBlank String name, + @Email @NotBlank String email, + String oneLineIntroduction, + String detailedIntroduction, + Long workedPeriod, + Integer mentoringPriceWon, + List tags, + List categories, + @Valid List careers +) { + public BackofficeExpertUpdateInput toInput(Long expertId) { + List careerInputs = careers == null + ? List.of() + : careers.stream() + .map(career -> new BackofficeExpertCareerUpdateInput( + career.id(), + career.orderIndex(), + career.careerTitle(), + career.careerExplanation(), + career.careerStartedAt(), + career.careerEndedAt() + )) + .toList(); + + return BackofficeExpertUpdateInput.of( + expertId, + name, + email, + oneLineIntroduction, + detailedIntroduction, + workedPeriod, + mentoringPriceWon, + tags, + categories, + careerInputs + ); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertCareerResponse.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertCareerResponse.java new file mode 100644 index 0000000..3086daf --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertCareerResponse.java @@ -0,0 +1,25 @@ +package starlight.adapter.backoffice.expert.webapi.dto.response; + +import starlight.application.expert.provided.dto.ExpertCareerResult; + +import java.time.LocalDateTime; + +public record BackofficeExpertCareerResponse( + Long id, + Integer orderIndex, + String careerTitle, + String careerExplanation, + LocalDateTime careerStartedAt, + LocalDateTime careerEndedAt +) { + public static BackofficeExpertCareerResponse from(ExpertCareerResult result) { + return new BackofficeExpertCareerResponse( + result.id(), + result.orderIndex(), + result.careerTitle(), + result.careerExplanation(), + result.careerStartedAt(), + result.careerEndedAt() + ); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertCreateResponse.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertCreateResponse.java new file mode 100644 index 0000000..b507bb5 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertCreateResponse.java @@ -0,0 +1,11 @@ +package starlight.adapter.backoffice.expert.webapi.dto.response; + +import starlight.application.backoffice.expert.provided.dto.result.BackofficeExpertCreateResult; + +public record BackofficeExpertCreateResponse( + Long id +) { + public static BackofficeExpertCreateResponse from(BackofficeExpertCreateResult result) { + return new BackofficeExpertCreateResponse(result.id()); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertDetailResponse.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertDetailResponse.java new file mode 100644 index 0000000..620ca93 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertDetailResponse.java @@ -0,0 +1,43 @@ +package starlight.adapter.backoffice.expert.webapi.dto.response; + +import starlight.application.backoffice.expert.provided.dto.result.BackofficeExpertDetailResult; + +import java.util.List; + +public record BackofficeExpertDetailResponse( + Long id, + Long applicationCount, + String name, + String oneLineIntroduction, + String detailedIntroduction, + String profileImageUrl, + Long workedPeriod, + String email, + Integer mentoringPriceWon, + String activeStatus, + List careers, + List tags, + List categories +) { + public static BackofficeExpertDetailResponse from(BackofficeExpertDetailResult result) { + List careers = result.careers().stream() + .map(BackofficeExpertCareerResponse::from) + .toList(); + + return new BackofficeExpertDetailResponse( + result.id(), + result.applicationCount(), + result.name(), + result.oneLineIntroduction(), + result.detailedIntroduction(), + result.profileImageUrl(), + result.workedPeriod(), + result.email(), + result.mentoringPriceWon(), + result.activeStatus().name(), + careers, + result.tags(), + result.categories() + ); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/swagger/BackofficeExpertApiDoc.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/swagger/BackofficeExpertApiDoc.java new file mode 100644 index 0000000..136e93d --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/swagger/BackofficeExpertApiDoc.java @@ -0,0 +1,269 @@ +package starlight.adapter.backoffice.expert.webapi.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import starlight.adapter.backoffice.expert.webapi.dto.request.BackofficeExpertActiveStatusUpdateRequest; +import starlight.adapter.backoffice.expert.webapi.dto.request.BackofficeExpertCreateRequest; +import starlight.adapter.backoffice.expert.webapi.dto.request.BackofficeExpertProfileImageUpdateRequest; +import starlight.adapter.backoffice.expert.webapi.dto.request.BackofficeExpertUpdateRequest; +import starlight.adapter.backoffice.expert.webapi.dto.response.BackofficeExpertCreateResponse; +import starlight.adapter.backoffice.expert.webapi.dto.response.BackofficeExpertDetailResponse; +import starlight.adapter.backoffice.expert.webapi.dto.response.BackofficeExpertListResponse; +import starlight.shared.apiPayload.response.ApiResponse; + +import java.util.List; + +@Tag(name = "[Office] 전문가", description = "백오피스 전문가 관리 API") +public interface BackofficeExpertApiDoc { + + @Operation( + summary = "전문가 목록 조회(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + array = @ArraySchema(schema = @Schema(implementation = BackofficeExpertListResponse.class)) + ) + ) + }) + @GetMapping + ApiResponse> searchAll(); + + @Operation( + summary = "전문가 생성(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content(schema = @Schema(implementation = BackofficeExpertCreateResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 값 오류", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + )) + ) + }) + @PostMapping + ApiResponse create( + @RequestBody BackofficeExpertCreateRequest request + ); + + @Operation( + summary = "전문가 상세 조회(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content(schema = @Schema(implementation = BackofficeExpertDetailResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "전문가 조회 실패", + content = @Content(examples = { + @ExampleObject( + name = "전문가 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_NOT_FOUND", + "message": "해당 전문가를 찾을 수 없습니다." + } + } + """ + ), + @ExampleObject( + name = "조회 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_QUERY_ERROR", + "message": "전문가 정보를 조회하는 중에 오류가 발생했습니다." + } + } + """ + ) + }) + ) + }) + @GetMapping("/{expertId}") + ApiResponse detail( + @PathVariable Long expertId + ); + + @Operation( + summary = "전문가 상세 수정(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 값 오류", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + )) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "전문가 조회 실패", + content = @Content(examples = { + @ExampleObject( + name = "전문가 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_NOT_FOUND", + "message": "해당 전문가를 찾을 수 없습니다." + } + } + """ + ), + @ExampleObject( + name = "조회 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_QUERY_ERROR", + "message": "전문가 정보를 조회하는 중에 오류가 발생했습니다." + } + } + """ + ) + }) + ) + }) + @PatchMapping("/{expertId}") + ApiResponse update( + @PathVariable Long expertId, + @RequestBody BackofficeExpertUpdateRequest request + ); + + @Operation( + summary = "전문가 활성 상태 변경(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공" + ) + }) + @PatchMapping("/{expertId}/active-status") + ApiResponse updateActiveStatus( + @PathVariable Long expertId, + @RequestBody BackofficeExpertActiveStatusUpdateRequest request + ); + + @Operation( + summary = "전문가 프로필 이미지 변경(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 값 오류", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + )) + ) + }) + @PatchMapping("/{expertId}/profile-image") + ApiResponse updateProfileImage( + @PathVariable Long expertId, + @RequestBody BackofficeExpertProfileImageUpdateRequest request + ); + + @Operation( + summary = "전문가 삭제(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "전문가 조회 실패", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_NOT_FOUND", + "message": "해당 전문가를 찾을 수 없습니다." + } + } + """ + )) + ) + }) + @DeleteMapping("/{expertId}") + ApiResponse delete( + @PathVariable Long expertId + ); +} diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java b/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java new file mode 100644 index 0000000..0621643 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java @@ -0,0 +1,45 @@ +package starlight.adapter.backoffice.image.webapi; + +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +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.application.aireport.required.PresignedUrlProvider; +import starlight.shared.apiPayload.response.ApiResponse; +import starlight.shared.dto.infrastructure.PreSignedUrlResponse; + +@Validated +@RestController +@RequiredArgsConstructor +@SecurityRequirement(name = "backofficeSession") +@RequestMapping("/v1/backoffice/images") +public class BackofficeImageController implements BackofficeImageApiDoc { + + private static final long BACKOFFICE_USER_ID = 0L; + + private final PresignedUrlProvider presignedUrlProvider; + + @GetMapping(value = "/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse getPresignedUrl( + @RequestParam @ValidImageFileName String fileName + ) { + return ApiResponse.success(presignedUrlProvider.getPreSignedUrl(BACKOFFICE_USER_ID, fileName)); + } + + @PostMapping(value = "/upload-url/public", consumes = MediaType.APPLICATION_JSON_VALUE) + public ApiResponse finalizePublic( + @Valid @RequestBody BackofficeImagePublicRequest request + ) { + return ApiResponse.success(presignedUrlProvider.makePublic(request.objectUrl())); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/dto/request/BackofficeImagePublicRequest.java b/src/main/java/starlight/adapter/backoffice/image/webapi/dto/request/BackofficeImagePublicRequest.java new file mode 100644 index 0000000..fb96bf5 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/dto/request/BackofficeImagePublicRequest.java @@ -0,0 +1,7 @@ +package starlight.adapter.backoffice.image.webapi.dto.request; + +import jakarta.validation.constraints.NotBlank; + +public record BackofficeImagePublicRequest( + @NotBlank String objectUrl +) { } 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 new file mode 100644 index 0000000..68f6d47 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java @@ -0,0 +1,86 @@ +package starlight.adapter.backoffice.image.webapi.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +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.shared.apiPayload.response.ApiResponse; +import starlight.shared.dto.infrastructure.PreSignedUrlResponse; + +@Tag(name = "[Office] 이미지", description = "백오피스 이미지 업로드 API") +public interface BackofficeImageApiDoc { + + @Operation( + summary = "Presigned URL 발급(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content( + schema = @Schema(implementation = PreSignedUrlResponse.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "fileName 검증 실패", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "fileName이 올바르지 않습니다." + } + } + """ + )) + ) + }) + @GetMapping(value = "/v1/backoffice/images/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) + ApiResponse getPresignedUrl( + @RequestParam String fileName + ); + + @Operation( + summary = "이미지 공개 전환(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 값 오류", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + )) + ) + }) + @PostMapping(value = "/v1/backoffice/images/upload-url/public", consumes = MediaType.APPLICATION_JSON_VALUE) + ApiResponse finalizePublic( + @RequestBody BackofficeImagePublicRequest request + ); +} diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileName.java b/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileName.java new file mode 100644 index 0000000..9727ada --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileName.java @@ -0,0 +1,23 @@ +package starlight.adapter.backoffice.image.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 = ValidImageFileNameValidator.class) +@Target({ElementType.PARAMETER, ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidImageFileName { + + String message() default "fileName이 올바르지 않습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileNameValidator.java b/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileNameValidator.java new file mode 100644 index 0000000..a43ef88 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/validation/ValidImageFileNameValidator.java @@ -0,0 +1,27 @@ +package starlight.adapter.backoffice.image.webapi.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import org.springframework.util.StringUtils; + +import java.util.regex.Pattern; + +public class ValidImageFileNameValidator implements ConstraintValidator { + + private static final Pattern FILE_NAME_PATTERN = + Pattern.compile("^[A-Za-z0-9._-]+\\.(png|jpg|jpeg|webp)$", Pattern.CASE_INSENSITIVE); + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + + if (!StringUtils.hasText(value)) { + return false; + } + + if (value.contains("/") || value.contains("\\")) { + return false; + } + + return FILE_NAME_PATTERN.matcher(value).matches(); + } +} diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java index fc4deee..e017521 100644 --- a/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java @@ -1,12 +1,13 @@ package starlight.adapter.backoffice.mail.webapi; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; import org.springframework.web.bind.annotation.*; import starlight.adapter.backoffice.mail.webapi.dto.request.BackofficeMailSendRequest; import starlight.adapter.backoffice.mail.webapi.dto.request.BackofficeMailTemplateCreateRequest; import starlight.adapter.backoffice.mail.webapi.dto.response.BackofficeMailTemplateResponse; +import starlight.adapter.backoffice.mail.webapi.swagger.BackofficeMailApiDoc; import starlight.application.backoffice.mail.provided.BackofficeMailSendUseCase; import starlight.application.backoffice.mail.provided.BackofficeMailTemplateUseCase; import starlight.shared.apiPayload.response.ApiResponse; @@ -16,7 +17,7 @@ @RestController @RequiredArgsConstructor @SecurityRequirement(name = "backofficeSession") -public class BackofficeMailController { +public class BackofficeMailController implements BackofficeMailApiDoc { private final BackofficeMailSendUseCase backofficeMailSendUseCase; private final BackofficeMailTemplateUseCase templateUseCase; diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/swagger/BackofficeMailApiDoc.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/swagger/BackofficeMailApiDoc.java new file mode 100644 index 0000000..be24f35 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/swagger/BackofficeMailApiDoc.java @@ -0,0 +1,135 @@ +package starlight.adapter.backoffice.mail.webapi.swagger; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import starlight.adapter.backoffice.mail.webapi.dto.request.BackofficeMailSendRequest; +import starlight.adapter.backoffice.mail.webapi.dto.request.BackofficeMailTemplateCreateRequest; +import starlight.adapter.backoffice.mail.webapi.dto.response.BackofficeMailTemplateResponse; +import starlight.shared.apiPayload.response.ApiResponse; + +import java.util.List; + +@Tag(name = "[Office] 메일", description = "백오피스 메일 관리 API") +public interface BackofficeMailApiDoc { + + @Operation( + summary = "백오피스 메일 발송", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "SUCCESS", + "data": "이메일 전송에 성공하였습니다.", + "error": null + } + """ + )) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 값 오류", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + )) + ) + }) + @PostMapping("/v1/backoffice/mail/send") + ApiResponse send( + @RequestBody BackofficeMailSendRequest request + ); + + @Operation( + summary = "메일 템플릿 생성", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content(schema = @Schema(implementation = BackofficeMailTemplateResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 값 오류", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + )) + ) + }) + @PostMapping("/v1/backoffice/mail/templates") + ApiResponse createTemplate( + @RequestBody BackofficeMailTemplateCreateRequest request + ); + + @Operation( + summary = "메일 템플릿 목록 조회", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = BackofficeMailTemplateResponse.class))) + ) + }) + @GetMapping("/v1/backoffice/mail/templates") + ApiResponse> findTemplates(); + + @Operation( + summary = "메일 템플릿 삭제", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "성공", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "SUCCESS", + "data": "템플릿이 삭제되었습니다.", + "error": null + } + """ + )) + ) + }) + @DeleteMapping("/v1/backoffice/mail/templates/{templateId}") + ApiResponse deleteTemplate( + @PathVariable Long templateId + ); +} diff --git a/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java b/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java index deae9a0..519bb66 100644 --- a/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java +++ b/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java @@ -19,6 +19,7 @@ @RequiredArgsConstructor public class ExpertJpa implements ExpertQueryPort, starlight.application.backoffice.expert.required.BackofficeExpertQueryPort, + starlight.application.backoffice.expert.required.BackofficeExpertCommandPort, starlight.application.expertReport.required.ExpertLookupPort, starlight.application.expertApplication.required.ExpertLookupPort { @@ -31,6 +32,32 @@ public Expert findByIdOrThrow(Long id) { ); } + @Override + public Expert findByIdWithCareersTagsCategories(Long id) { + try { + List experts = fetchWithCollections(List.of(id)); + if (experts.isEmpty()) { + throw new ExpertException(ExpertErrorType.EXPERT_NOT_FOUND); + } + return experts.get(0); + } catch (ExpertException e) { + throw e; + } catch (Exception e) { + log.error("전문가 상세 조회 중 오류가 발생했습니다.", e); + throw new ExpertException(ExpertErrorType.EXPERT_QUERY_ERROR); + } + } + + @Override + public Expert save(Expert expert) { + return repository.save(expert); + } + + @Override + public void delete(Expert expert) { + repository.delete(expert); + } + @Override public Expert findByIdWithCareersAndTags(Long id) { try { diff --git a/src/main/java/starlight/application/backoffice/expert/BackofficeExpertCommandService.java b/src/main/java/starlight/application/backoffice/expert/BackofficeExpertCommandService.java new file mode 100644 index 0000000..faea25f --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/BackofficeExpertCommandService.java @@ -0,0 +1,99 @@ +package starlight.application.backoffice.expert; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import starlight.application.backoffice.expert.provided.BackofficeExpertCommandUseCase; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertActiveStatusUpdateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertCreateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertProfileImageUpdateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertCareerUpdateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertUpdateInput; +import starlight.application.backoffice.expert.provided.dto.result.BackofficeExpertCreateResult; +import starlight.application.backoffice.expert.required.BackofficeExpertCommandPort; +import starlight.application.backoffice.expert.required.BackofficeExpertQueryPort; +import starlight.domain.expert.entity.Expert; +import starlight.domain.expert.dto.ExpertCareerUpdate; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class BackofficeExpertCommandService implements BackofficeExpertCommandUseCase { + + private final BackofficeExpertCommandPort expertCommandPort; + private final BackofficeExpertQueryPort expertQueryPort; + + @Override + public BackofficeExpertCreateResult createExpert(BackofficeExpertCreateInput input) { + Expert expert = Expert.createBackoffice( + input.name(), + input.email(), + input.oneLineIntroduction(), + input.tags(), + input.categories() + ); + + Expert savedExpert = expertCommandPort.save(expert); + + return BackofficeExpertCreateResult.from(savedExpert.getId()); + } + + @Override + public void updateExpert(BackofficeExpertUpdateInput input) { + Expert expert = expertQueryPort.findByIdWithCareersTagsCategories(input.expertId()); + + expert.updateBasicInfo( + input.name(), + input.email(), + input.oneLineIntroduction(), + input.detailedIntroduction(), + input.workedPeriod(), + input.mentoringPriceWon() + ); + + expert.replaceTags(input.tags()); + expert.replaceCategories(input.categories()); + + expert.syncCareers(toCareerUpdates(input.careers())); + } + + @Override + public void deleteExpert(Long expertId) { + Expert expert = expertQueryPort.findByIdOrThrow(expertId); + + expertCommandPort.delete(expert); + } + + @Override + public void updateActiveStatus(BackofficeExpertActiveStatusUpdateInput input) { + Expert expert = expertQueryPort.findByIdOrThrow(input.expertId()); + + expert.updateActiveStatus(input.activeStatus()); + } + + @Override + public void updateProfileImage(BackofficeExpertProfileImageUpdateInput input) { + Expert expert = expertQueryPort.findByIdOrThrow(input.expertId()); + + expert.updateProfileImageUrl(input.profileImageUrl()); + } + + private List toCareerUpdates(List inputs) { + if (inputs == null || inputs.isEmpty()) { + return List.of(); + } + + return inputs.stream() + .map(input -> new ExpertCareerUpdate( + input.id(), + input.orderIndex(), + input.careerTitle(), + input.careerExplanation(), + input.careerStartedAt(), + input.careerEndedAt() + )) + .toList(); + } +} diff --git a/src/main/java/starlight/application/backoffice/expert/BackofficeExpertQueryService.java b/src/main/java/starlight/application/backoffice/expert/BackofficeExpertQueryService.java index 9d7f640..57ac2ae 100644 --- a/src/main/java/starlight/application/backoffice/expert/BackofficeExpertQueryService.java +++ b/src/main/java/starlight/application/backoffice/expert/BackofficeExpertQueryService.java @@ -31,7 +31,22 @@ public List searchAll() { Map countMap = expertApplicationLookupPort.countByExpertIds(expertIds); return experts.stream() - .map(expert -> BackofficeExpertDetailResult.from(expert, countMap.getOrDefault(expert.getId(), 0L))) + .map(expert -> BackofficeExpertDetailResult.from( + expert, + countMap.getOrDefault(expert.getId(), 0L) + )) .toList(); } + + @Override + public BackofficeExpertDetailResult findById(Long expertId) { + Expert expert = expertQueryPort.findByIdOrThrow(expertId); + + Map countMap = expertApplicationLookupPort.countByExpertIds( + List.of(expertId) + ); + long count = countMap.getOrDefault(expertId, 0L); + + return BackofficeExpertDetailResult.from(expert, count); + } } diff --git a/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertCommandUseCase.java b/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertCommandUseCase.java new file mode 100644 index 0000000..0d8ef20 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertCommandUseCase.java @@ -0,0 +1,20 @@ +package starlight.application.backoffice.expert.provided; + +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertActiveStatusUpdateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertCreateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertProfileImageUpdateInput; +import starlight.application.backoffice.expert.provided.dto.input.BackofficeExpertUpdateInput; +import starlight.application.backoffice.expert.provided.dto.result.BackofficeExpertCreateResult; + +public interface BackofficeExpertCommandUseCase { + + BackofficeExpertCreateResult createExpert(BackofficeExpertCreateInput input); + + void updateExpert(BackofficeExpertUpdateInput input); + + void deleteExpert(Long expertId); + + void updateActiveStatus(BackofficeExpertActiveStatusUpdateInput input); + + void updateProfileImage(BackofficeExpertProfileImageUpdateInput input); +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertQueryUseCase.java b/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertQueryUseCase.java index 97b4a7c..1abf2b4 100644 --- a/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertQueryUseCase.java +++ b/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertQueryUseCase.java @@ -7,4 +7,6 @@ public interface BackofficeExpertQueryUseCase { List searchAll(); + + BackofficeExpertDetailResult findById(Long expertId); } diff --git a/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertActiveStatusUpdateInput.java b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertActiveStatusUpdateInput.java new file mode 100644 index 0000000..95f5c52 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertActiveStatusUpdateInput.java @@ -0,0 +1,12 @@ +package starlight.application.backoffice.expert.provided.dto.input; + +import starlight.domain.expert.enumerate.ExpertActiveStatus; + +public record BackofficeExpertActiveStatusUpdateInput( + Long expertId, + ExpertActiveStatus activeStatus +) { + public static BackofficeExpertActiveStatusUpdateInput of(Long expertId, ExpertActiveStatus activeStatus) { + return new BackofficeExpertActiveStatusUpdateInput(expertId, activeStatus); + } +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertCareerUpdateInput.java b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertCareerUpdateInput.java new file mode 100644 index 0000000..8ad9570 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertCareerUpdateInput.java @@ -0,0 +1,13 @@ +package starlight.application.backoffice.expert.provided.dto.input; + +import java.time.LocalDateTime; + +public record BackofficeExpertCareerUpdateInput( + Long id, + Integer orderIndex, + String careerTitle, + String careerExplanation, + LocalDateTime careerStartedAt, + LocalDateTime careerEndedAt +) { +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertCreateInput.java b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertCreateInput.java new file mode 100644 index 0000000..cca4a89 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertCreateInput.java @@ -0,0 +1,23 @@ +package starlight.application.backoffice.expert.provided.dto.input; + +import starlight.domain.expert.enumerate.TagCategory; + +import java.util.List; + +public record BackofficeExpertCreateInput( + String name, + String email, + String oneLineIntroduction, + List tags, + List categories +) { + public static BackofficeExpertCreateInput of( + String name, + String email, + String oneLineIntroduction, + List tags, + List categories + ) { + return new BackofficeExpertCreateInput(name, email, oneLineIntroduction, tags, categories); + } +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertProfileImageUpdateInput.java b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertProfileImageUpdateInput.java new file mode 100644 index 0000000..24a92a2 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertProfileImageUpdateInput.java @@ -0,0 +1,10 @@ +package starlight.application.backoffice.expert.provided.dto.input; + +public record BackofficeExpertProfileImageUpdateInput( + Long expertId, + String profileImageUrl +) { + public static BackofficeExpertProfileImageUpdateInput of(Long expertId, String profileImageUrl) { + return new BackofficeExpertProfileImageUpdateInput(expertId, profileImageUrl); + } +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertUpdateInput.java b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertUpdateInput.java new file mode 100644 index 0000000..2d7fe44 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/dto/input/BackofficeExpertUpdateInput.java @@ -0,0 +1,44 @@ +package starlight.application.backoffice.expert.provided.dto.input; + +import starlight.domain.expert.enumerate.TagCategory; + +import java.util.List; + +public record BackofficeExpertUpdateInput( + Long expertId, + String name, + String email, + String oneLineIntroduction, + String detailedIntroduction, + Long workedPeriod, + Integer mentoringPriceWon, + List tags, + List categories, + List careers +) { + public static BackofficeExpertUpdateInput of( + Long expertId, + String name, + String email, + String oneLineIntroduction, + String detailedIntroduction, + Long workedPeriod, + Integer mentoringPriceWon, + List tags, + List categories, + List careers + ) { + return new BackofficeExpertUpdateInput( + expertId, + name, + email, + oneLineIntroduction, + detailedIntroduction, + workedPeriod, + mentoringPriceWon, + tags, + categories, + careers + ); + } +} diff --git a/src/main/java/starlight/application/backoffice/expert/provided/dto/result/BackofficeExpertCreateResult.java b/src/main/java/starlight/application/backoffice/expert/provided/dto/result/BackofficeExpertCreateResult.java new file mode 100644 index 0000000..7514b81 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/dto/result/BackofficeExpertCreateResult.java @@ -0,0 +1,9 @@ +package starlight.application.backoffice.expert.provided.dto.result; + +public record BackofficeExpertCreateResult( + Long id +) { + public static BackofficeExpertCreateResult from(Long id) { + return new BackofficeExpertCreateResult(id); + } +} diff --git a/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertCommandPort.java b/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertCommandPort.java new file mode 100644 index 0000000..3b32994 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertCommandPort.java @@ -0,0 +1,10 @@ +package starlight.application.backoffice.expert.required; + +import starlight.domain.expert.entity.Expert; + +public interface BackofficeExpertCommandPort { + + Expert save(Expert expert); + + void delete(Expert expert); +} diff --git a/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertQueryPort.java b/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertQueryPort.java index 381519b..c6da318 100644 --- a/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertQueryPort.java +++ b/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertQueryPort.java @@ -6,5 +6,9 @@ public interface BackofficeExpertQueryPort { + Expert findByIdOrThrow(Long id); + + Expert findByIdWithCareersTagsCategories(Long id); + List findAllWithCareersTagsCategories(); } diff --git a/src/main/java/starlight/domain/expert/dto/ExpertCareerUpdate.java b/src/main/java/starlight/domain/expert/dto/ExpertCareerUpdate.java new file mode 100644 index 0000000..a4e0fa5 --- /dev/null +++ b/src/main/java/starlight/domain/expert/dto/ExpertCareerUpdate.java @@ -0,0 +1,12 @@ +package starlight.domain.expert.dto; + +import java.time.LocalDateTime; + +public record ExpertCareerUpdate( + Long id, + Integer orderIndex, + String careerTitle, + String careerExplanation, + LocalDateTime careerStartedAt, + LocalDateTime careerEndedAt +) { } diff --git a/src/main/java/starlight/domain/expert/entity/Expert.java b/src/main/java/starlight/domain/expert/entity/Expert.java index 8ef04d3..2107249 100644 --- a/src/main/java/starlight/domain/expert/entity/Expert.java +++ b/src/main/java/starlight/domain/expert/entity/Expert.java @@ -5,14 +5,23 @@ import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import org.springframework.util.Assert; import starlight.domain.expert.enumerate.ExpertActiveStatus; import starlight.domain.expert.enumerate.TagCategory; +import starlight.domain.expert.dto.ExpertCareerUpdate; +import starlight.domain.expert.exception.ExpertErrorType; +import starlight.domain.expert.exception.ExpertException; import starlight.shared.AbstractEntity; import java.util.ArrayList; +import java.util.Collection; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; @Getter @Entity @@ -59,4 +68,151 @@ public class Expert extends AbstractEntity { @Enumerated(EnumType.STRING) @Column(name = "category", length = 40, nullable = false) private Set categories = new LinkedHashSet<>(); + + public static Expert createBackoffice( + String name, + String email, + String oneLineIntroduction, + Collection tags, + Collection categories + ) { + Assert.hasText(name, "name must not be blank"); + Assert.hasText(email, "email must not be blank"); + + Expert expert = new Expert(); + expert.name = name; + expert.email = email; + expert.oneLineIntroduction = oneLineIntroduction; + expert.activeStatus = ExpertActiveStatus.INACTIVE; + + if (tags != null && !tags.isEmpty()) { + expert.tags.clear(); + expert.tags.addAll(tags); + } + + if (categories != null && !categories.isEmpty()) { + expert.categories.clear(); + expert.categories.addAll(categories); + } + + return expert; + } + + public void updateActiveStatus(ExpertActiveStatus activeStatus) { + Assert.notNull(activeStatus, "activeStatus must not be null"); + this.activeStatus = activeStatus; + } + + public void updateProfileImageUrl(String profileImageUrl) { + Assert.hasText(profileImageUrl, "profileImageUrl must not be blank"); + this.profileImageUrl = profileImageUrl; + } + + public void updateBasicInfo( + String name, String email, String oneLineIntroduction, + String detailedIntroduction, Long workedPeriod, Integer mentoringPriceWon + ) { + Assert.hasText(name, "name must not be blank"); + Assert.hasText(email, "email must not be blank"); + this.name = name; + this.email = email; + this.oneLineIntroduction = oneLineIntroduction; + this.detailedIntroduction = detailedIntroduction; + this.workedPeriod = workedPeriod; + this.mentoringPriceWon = mentoringPriceWon; + } + + public void replaceTags(Collection tags) { + this.tags.clear(); + if (tags != null && !tags.isEmpty()) { + this.tags.addAll(tags); + } + } + + public void replaceCategories(Collection categories) { + this.categories.clear(); + if (categories != null && !categories.isEmpty()) { + this.categories.addAll(categories); + } + } + + public void syncCareers(List updates) { + List careerUpdates = updates != null ? updates : List.of(); + + validateCareerUpdates(careerUpdates); + + Map careerById = careers.stream() + .filter(career -> career.getId() != null) + .collect(Collectors.toMap( + ExpertCareer::getId, + Function.identity(), + (a, b) -> a + )); + + Set requestedIds = careerUpdates.stream() + .map(ExpertCareerUpdate::id) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + careers.removeIf(career -> + career.getId() != null && !requestedIds.contains(career.getId()) + ); + + for (ExpertCareerUpdate update : careerUpdates) { + if (update.id() == null) { + careers.add(ExpertCareer.of( + this, + update.orderIndex(), + update.careerTitle(), + update.careerExplanation(), + update.careerStartedAt(), + update.careerEndedAt() + )); + continue; + } + + ExpertCareer career = careerById.get(update.id()); + if (career == null) { + throw new ExpertException(ExpertErrorType.EXPERT_NOT_FOUND); + } + + career.update( + update.orderIndex(), + update.careerTitle(), + update.careerExplanation(), + update.careerStartedAt(), + update.careerEndedAt() + ); + } + } + + private void validateCareerUpdates(List careerUpdates) { + Set orderIndexes = careerUpdates.stream() + .map(ExpertCareerUpdate::orderIndex) + .collect(Collectors.toSet()); + + Set requestedIds = careerUpdates.stream() + .map(ExpertCareerUpdate::id) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + long requestedIdCount = careerUpdates.stream() + .map(ExpertCareerUpdate::id) + .filter(Objects::nonNull) + .count(); + + boolean hasDuplicateOrderIndex = orderIndexes.size() != careerUpdates.size(); + boolean hasDuplicateIds = requestedIds.size() != requestedIdCount; + boolean hasInvalidPeriod = careerUpdates.stream().anyMatch(update -> + update.orderIndex() == null + || update.orderIndex() < 0 + || update.careerStartedAt() == null + || update.careerEndedAt() == null + || update.careerStartedAt().isAfter(update.careerEndedAt()) + ); + + if (hasDuplicateOrderIndex || hasDuplicateIds || hasInvalidPeriod) { + throw new ExpertException(ExpertErrorType.EXPERT_CAREER_INVALID); + } + } } diff --git a/src/main/java/starlight/domain/expert/entity/ExpertCareer.java b/src/main/java/starlight/domain/expert/entity/ExpertCareer.java index cd151ca..964699e 100644 --- a/src/main/java/starlight/domain/expert/entity/ExpertCareer.java +++ b/src/main/java/starlight/domain/expert/entity/ExpertCareer.java @@ -44,7 +44,8 @@ public static ExpertCareer of(Expert expert, int orderIndex, String title, Strin return expertCareer; } - public void update(String title, String explanation, LocalDateTime startedAt, LocalDateTime endedAt) { + public void update(int orderIndex, String title, String explanation, LocalDateTime startedAt, LocalDateTime endedAt) { + this.orderIndex = orderIndex; this.careerTitle = title; this.careerExplanation = explanation; this.careerStartedAt = startedAt; diff --git a/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java b/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java index e8179cd..144815a 100644 --- a/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java +++ b/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java @@ -9,8 +9,9 @@ @RequiredArgsConstructor public enum ExpertErrorType implements ErrorType { - EXPERT_QUERY_ERROR(HttpStatus.NOT_FOUND, "전문가 정보를 조회하는 중에 오류가 발생했습니다."), - EXPERT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 전문가를 찾을 수 없습니다."); + EXPERT_QUERY_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "전문가 정보를 조회하는 중에 오류가 발생했습니다."), + EXPERT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 전문가를 찾을 수 없습니다."), + EXPERT_CAREER_INVALID(HttpStatus.BAD_REQUEST, "경력 정보가 올바르지 않습니다."); ; private final HttpStatus status; 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 c45767a..de2440f 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" @@ -56,5 +56,21 @@ - Application 입력: `*Input` - Application 출력: `*Result` +## API 응답 규칙 +- 조회 API는 항상 데이터를 반환한다. +- 생성 API는 식별자 또는 핵심 결과만 반환한다. +- 수정/삭제 API는 기본적으로 `ApiResponse.success()`로 통일한다(응답 data 없음). +- 사용자 메시지가 필요한 액션(메일 전송 등)만 메시지 포함 응답을 사용한다. + +## 포맷팅 규칙 +- 컨트롤러 호출이 ~100자 이내면 한 줄로 유지한다. +- 인자가 래핑되면 한 줄에 한 인자로 멀티라인을 유지한다. +- 빌더나 `*Input.of(...)`는 인자가 2개 이상이면 멀티라인을 우선한다. +- 논리 단계별로 빈 줄을 넣어 구분한다(예: 조회 → 계산 → 반환). + +## 도메인 검증 규칙 +- `Assert`는 프로그래머 오류/불변식 위반에만 사용한다. +- 비즈니스 규칙 위반/사용자 입력 오류는 도메인 예외로 처리한다. + ## 로컬 실행 - `./gradlew bootRun --args='--spring.profiles.active=dev'` From c94e688b155464ca1c32f65222c6a31a1b6e3e9b Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Sun, 25 Jan 2026 15:09:20 +0900 Subject: [PATCH 66/74] =?UTF-8?q?[SRLT-132]=20Refactor:=20=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EB=A7=88=EC=8A=A4=ED=82=B9=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A1=9C=EA=B9=85/=EC=98=A4=EB=A5=98=20=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0=20-=20EmailMaskingUtils=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=8B=A0=EC=9E=90=20=EC=9D=B4=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EB=A7=88=EC=8A=A4=ED=82=B9=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20-=20BackofficeMailContentTypeParser=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85=EC=9C=BC=EB=A1=9C=20ContentType=20=ED=8C=8C?= =?UTF-8?q?=EC=8B=B1=20=EB=A1=9C=EC=A7=81=20=EC=A0=95=EB=A6=AC/=EC=9E=AC?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20-=20=EB=A1=9C=EA=B7=B8=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=88=98=EC=8B=A0?= =?UTF-8?q?=EC=9E=90=EC=9D=98=20=EC=88=98=EB=9F=89=EB=A7=8C=20=EB=A1=9C?= =?UTF-8?q?=EA=B9=85=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD=20-?= =?UTF-8?q?=20=EB=A1=9C=EA=B9=85=20=EB=B0=8F=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EC=8B=9C=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0=20(BackofficeException?= =?UTF-8?q?=20=ED=99=9C=EC=9A=A9)=20-=20CSRF=20=EC=BF=A0=ED=82=A4=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=84=A4=EC=A0=95=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=ED=99=94=20(application-{env}.yaml=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 --- .../backoffice/mail/email/SmtpMailSender.java | 4 +-- .../BackofficeMailSendLogEventHandler.java | 14 ++++++-- .../mail/BackofficeMailSendService.java | 29 ++++++---------- .../mail/BackofficeMailTemplateService.java | 12 ++----- .../util/BackofficeMailContentTypeParser.java | 18 ++++++++++ .../mail/util/EmailMaskingUtils.java | 34 +++++++++++++++++++ .../starlight/bootstrap/SecurityConfig.java | 7 ++-- 7 files changed, 84 insertions(+), 34 deletions(-) create mode 100644 src/main/java/starlight/application/backoffice/mail/util/BackofficeMailContentTypeParser.java create mode 100644 src/main/java/starlight/application/backoffice/mail/util/EmailMaskingUtils.java diff --git a/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java b/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java index c5c3d6f..0eda1a1 100644 --- a/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java +++ b/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java @@ -39,9 +39,9 @@ public void send(BackofficeMailSendInput input, BackofficeMailContentType conten helper.setText(body, isHtml); javaMailSender.send(message); - log.info("[MAIL] sent to={} subject={}", input.to(), input.subject()); + log.info("[MAIL] sent recipients={} subject={}", input.to().size(), input.subject()); } catch (MessagingException e) { - log.error("[MAIL] send failed to={}", input.to(), e); + log.error("[MAIL] send failed recipients={}", input.to().size(), e); throw new BackofficeException(BackofficeErrorType.MAIL_SEND_FAILED, e); } } diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java index 4192826..0bb1d9a 100644 --- a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java @@ -2,9 +2,13 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.event.EventListener; +import org.springframework.dao.DataAccessException; import org.springframework.stereotype.Component; import starlight.application.backoffice.mail.event.BackofficeMailSendEvent; import starlight.application.backoffice.mail.required.BackofficeMailSendLogCommandPort; +import starlight.application.backoffice.mail.util.EmailMaskingUtils; +import starlight.domain.backoffice.exception.BackofficeErrorType; +import starlight.domain.backoffice.exception.BackofficeException; import starlight.domain.backoffice.mail.BackofficeMailSendLog; @Component @@ -15,7 +19,8 @@ public class BackofficeMailSendLogEventHandler { @EventListener public void handle(BackofficeMailSendEvent event) { - String recipients = String.join(",", event.to()); + String recipients = EmailMaskingUtils.maskRecipients(event.to()); + BackofficeMailSendLog log = BackofficeMailSendLog.create( recipients, event.subject(), @@ -23,6 +28,11 @@ public void handle(BackofficeMailSendEvent event) { event.success(), event.errorMessage() ); - logCommandPort.save(log); + + try { + logCommandPort.save(log); + } catch (DataAccessException exception) { + throw new BackofficeException(BackofficeErrorType.MAIL_LOG_SAVE_FAILED); + } } } diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java index 0724378..536b72f 100644 --- a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java @@ -1,17 +1,17 @@ package starlight.application.backoffice.mail; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import starlight.application.backoffice.mail.provided.BackofficeMailSendUseCase; import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendInput; import starlight.application.backoffice.mail.required.MailSenderPort; +import starlight.application.backoffice.mail.util.BackofficeMailContentTypeParser; import starlight.application.backoffice.mail.event.BackofficeMailSendEvent; -import org.springframework.context.ApplicationEventPublisher; import starlight.domain.backoffice.exception.BackofficeErrorType; import starlight.domain.backoffice.exception.BackofficeException; import starlight.domain.backoffice.mail.BackofficeMailContentType; -import starlight.domain.backoffice.mail.BackofficeMailSendLog; @Service @RequiredArgsConstructor @@ -23,11 +23,13 @@ public class BackofficeMailSendService implements BackofficeMailSendUseCase { @Override @Transactional public void send(BackofficeMailSendInput input) { - BackofficeMailContentType contentType = parseContentType(input.contentType()); + BackofficeMailContentType contentType = BackofficeMailContentTypeParser.parse(input.contentType()); try { validate(input, contentType); + mailSenderPort.send(input, contentType); + BackofficeMailSendEvent log = BackofficeMailSendEvent.of( input.to(), input.subject(), @@ -36,39 +38,30 @@ public void send(BackofficeMailSendInput input) { null ); eventPublisher.publishEvent(log); - - } catch (IllegalArgumentException exception) { + } catch (BackofficeException exception) { publishFailureEvent(input, contentType, exception.getMessage()); - throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_REQUEST); + throw exception; } catch (Exception exception) { publishFailureEvent(input, contentType, exception.getMessage()); throw new BackofficeException(BackofficeErrorType.MAIL_SEND_FAILED); } } - private BackofficeMailContentType parseContentType(String contentType) { - try { - return BackofficeMailContentType.from(contentType); - } catch (IllegalArgumentException exception) { - throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_CONTENT_TYPE); - } - } - private void validate(BackofficeMailSendInput input, BackofficeMailContentType contentType) { if (input.to() == null || input.to().isEmpty()) { - throw new IllegalArgumentException("recipient is required"); + throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_REQUEST); } if (input.subject() == null || input.subject().isBlank()) { - throw new IllegalArgumentException("subject is required"); + throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_REQUEST); } if (contentType == BackofficeMailContentType.HTML) { if (input.html() == null || input.html().isBlank()) { - throw new IllegalArgumentException("html body is required"); + throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_REQUEST); } } if (contentType == BackofficeMailContentType.TEXT) { if (input.text() == null || input.text().isBlank()) { - throw new IllegalArgumentException("text body is required"); + throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_REQUEST); } } } diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailTemplateService.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailTemplateService.java index bdd12e9..2d58807 100644 --- a/src/main/java/starlight/application/backoffice/mail/BackofficeMailTemplateService.java +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailTemplateService.java @@ -9,6 +9,7 @@ import starlight.application.backoffice.mail.provided.dto.result.BackofficeMailTemplateResult; import starlight.application.backoffice.mail.required.BackofficeMailTemplateCommandPort; import starlight.application.backoffice.mail.required.BackofficeMailTemplateQueryPort; +import starlight.application.backoffice.mail.util.BackofficeMailContentTypeParser; import starlight.domain.backoffice.exception.BackofficeErrorType; import starlight.domain.backoffice.exception.BackofficeException; import starlight.domain.backoffice.mail.BackofficeMailContentType; @@ -26,7 +27,7 @@ public class BackofficeMailTemplateService implements BackofficeMailTemplateUseC @Override @Transactional public BackofficeMailTemplateResult createTemplate(BackofficeMailTemplateCreateInput input) { - BackofficeMailContentType contentType = parseContentType(input.contentType()); + BackofficeMailContentType contentType = BackofficeMailContentTypeParser.parse(input.contentType()); BackofficeMailTemplate template = BackofficeMailTemplate.create( input.name(), input.title(), @@ -37,20 +38,13 @@ public BackofficeMailTemplateResult createTemplate(BackofficeMailTemplateCreateI try { BackofficeMailTemplate saved = templateCommandPort.save(template); + return toResult(saved); } catch (DataAccessException exception) { throw new BackofficeException(BackofficeErrorType.MAIL_TEMPLATE_SAVE_FAILED); } } - private BackofficeMailContentType parseContentType(String contentType) { - try { - return BackofficeMailContentType.from(contentType); - } catch (IllegalArgumentException exception) { - throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_CONTENT_TYPE); - } - } - @Override @Transactional(readOnly = true) public List findTemplates() { diff --git a/src/main/java/starlight/application/backoffice/mail/util/BackofficeMailContentTypeParser.java b/src/main/java/starlight/application/backoffice/mail/util/BackofficeMailContentTypeParser.java new file mode 100644 index 0000000..6bf8717 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/util/BackofficeMailContentTypeParser.java @@ -0,0 +1,18 @@ +package starlight.application.backoffice.mail.util; + +import starlight.domain.backoffice.exception.BackofficeErrorType; +import starlight.domain.backoffice.exception.BackofficeException; +import starlight.domain.backoffice.mail.BackofficeMailContentType; + +public final class BackofficeMailContentTypeParser { + + private BackofficeMailContentTypeParser() {} + + public static BackofficeMailContentType parse(String contentType) { + try { + return BackofficeMailContentType.from(contentType); + } catch (IllegalArgumentException exception) { + throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_CONTENT_TYPE); + } + } +} diff --git a/src/main/java/starlight/application/backoffice/mail/util/EmailMaskingUtils.java b/src/main/java/starlight/application/backoffice/mail/util/EmailMaskingUtils.java new file mode 100644 index 0000000..9c35246 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/util/EmailMaskingUtils.java @@ -0,0 +1,34 @@ +package starlight.application.backoffice.mail.util; + +import java.util.List; +import java.util.stream.Collectors; + +public final class EmailMaskingUtils { + + private EmailMaskingUtils() { + } + + public static String maskRecipients(List recipients) { + if (recipients == null || recipients.isEmpty()) { + return ""; + } + return recipients.stream() + .map(EmailMaskingUtils::maskEmail) + .collect(Collectors.joining(",")); + } + + private static String maskEmail(String email) { + if (email == null || email.isBlank()) { + return "***"; + } + int atIndex = email.indexOf("@"); + if (atIndex <= 0) { + return "***"; + } + String local = email.substring(0, atIndex); + String domain = email.substring(atIndex + 1); + String maskedLocal = local.length() <= 1 ? "*" : local.charAt(0) + "***"; + String maskedDomain = domain.isBlank() ? "***" : domain; + return maskedLocal + "@" + maskedDomain; + } +} diff --git a/src/main/java/starlight/bootstrap/SecurityConfig.java b/src/main/java/starlight/bootstrap/SecurityConfig.java index 055e7ad..4eeecc1 100644 --- a/src/main/java/starlight/bootstrap/SecurityConfig.java +++ b/src/main/java/starlight/bootstrap/SecurityConfig.java @@ -47,12 +47,13 @@ @RequiredArgsConstructor public class SecurityConfig { - @Value("${cors.origin.server}") String ServerBaseUrl; + @Value("${cors.origin.server}") String serverBaseUrl; @Value("${cors.origin.client}") String clientBaseUrl; @Value("${cors.origin.office}") String officeBaseUrl; @Value("${cors.origin.develop}") String devBaseUrl; @Value("${backoffice.auth.username}") String backofficeUsername; @Value("${backoffice.auth.password-hash}") String backofficePasswordHash; + @Value("${backoffice.csrf.cookie-domain}") String backofficeCsrfCookieDomain; private final Environment environment; private final JwtFilter jwtFilter; @@ -70,7 +71,7 @@ public SecurityFilterChain backofficeFilterChain(HttpSecurity http) throws Excep boolean isDevProfile = List.of(environment.getActiveProfiles()).contains("dev"); if (!isDevProfile) { csrfTokenRepository.setCookieCustomizer(cookie -> cookie - .domain("starlight-official.co.kr") + .domain(backofficeCsrfCookieDomain) .sameSite("None") .secure(true) ); @@ -148,7 +149,7 @@ public CorsConfigurationSource corsConfigurationSource() { configuration.setAllowedOrigins(List.of( clientBaseUrl, - ServerBaseUrl, + serverBaseUrl, devBaseUrl, officeBaseUrl )); From 8168f2bb191597769ffcfa3f37bb50468b348045 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Sun, 25 Jan 2026 15:15:49 +0900 Subject: [PATCH 67/74] =?UTF-8?q?[SRLT-133]=20Refactor:=20=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EC=A0=84=EC=86=A1=20=EC=97=90=EB=9F=AC=EA=B0=80=20?= =?UTF-8?q?=EC=A0=84=ED=8C=8C=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mail/BackofficeMailSendLogEventHandler.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java index 0bb1d9a..4db43b7 100644 --- a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java @@ -1,23 +1,27 @@ package starlight.application.backoffice.mail; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; import org.springframework.dao.DataAccessException; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; import starlight.application.backoffice.mail.event.BackofficeMailSendEvent; import starlight.application.backoffice.mail.required.BackofficeMailSendLogCommandPort; import starlight.application.backoffice.mail.util.EmailMaskingUtils; -import starlight.domain.backoffice.exception.BackofficeErrorType; -import starlight.domain.backoffice.exception.BackofficeException; import starlight.domain.backoffice.mail.BackofficeMailSendLog; +@Slf4j @Component @RequiredArgsConstructor public class BackofficeMailSendLogEventHandler { private final BackofficeMailSendLogCommandPort logCommandPort; - @EventListener + @Async("emailTaskExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handle(BackofficeMailSendEvent event) { String recipients = EmailMaskingUtils.maskRecipients(event.to()); @@ -32,7 +36,7 @@ public void handle(BackofficeMailSendEvent event) { try { logCommandPort.save(log); } catch (DataAccessException exception) { - throw new BackofficeException(BackofficeErrorType.MAIL_LOG_SAVE_FAILED); + log.warn("[MAIL] send log save failed. subject={}", event.subject(), exception); } } } From d7fba08447c5469a0f9d7c6ede95e4216b06887e Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Sun, 25 Jan 2026 15:46:03 +0900 Subject: [PATCH 68/74] =?UTF-8?q?[SRLT-133]=20Refactor:=20log=20=EB=B3=80?= =?UTF-8?q?=EC=88=98=EB=AA=85=20=EC=B6=A9=EB=8F=8C=EC=9D=84=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backoffice/mail/BackofficeMailSendLogEventHandler.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java index 4db43b7..3b09ddc 100644 --- a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java @@ -25,7 +25,7 @@ public class BackofficeMailSendLogEventHandler { public void handle(BackofficeMailSendEvent event) { String recipients = EmailMaskingUtils.maskRecipients(event.to()); - BackofficeMailSendLog log = BackofficeMailSendLog.create( + BackofficeMailSendLog mailSendLog = BackofficeMailSendLog.create( recipients, event.subject(), event.contentType(), @@ -34,7 +34,7 @@ public void handle(BackofficeMailSendEvent event) { ); try { - logCommandPort.save(log); + logCommandPort.save(mailSendLog); } catch (DataAccessException exception) { log.warn("[MAIL] send log save failed. subject={}", event.subject(), exception); } From c1f611f2bdc804f6ec51aa0861c544a76bd4a0cb Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Sun, 25 Jan 2026 18:17:42 +0900 Subject: [PATCH 69/74] =?UTF-8?q?[SRLT-133]=20Refactor:=20PresignedUrlProv?= =?UTF-8?q?ider=20=EC=9D=98=EC=A1=B4=EC=84=B1=EC=9D=84=20NcpPresignedUrlPr?= =?UTF-8?q?ovider=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backoffice/image/webapi/BackofficeImageController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 0621643..5d4c233 100644 --- a/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java @@ -11,10 +11,10 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import starlight.adapter.aireport.infrastructure.storage.NcpPresignedUrlProvider; 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.PresignedUrlProvider; import starlight.shared.apiPayload.response.ApiResponse; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; @@ -27,7 +27,7 @@ public class BackofficeImageController implements BackofficeImageApiDoc { private static final long BACKOFFICE_USER_ID = 0L; - private final PresignedUrlProvider presignedUrlProvider; + private final NcpPresignedUrlProvider presignedUrlProvider; @GetMapping(value = "/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) public ApiResponse getPresignedUrl( From 67fafedfda5a25c9724a3137a68149f944505fde Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Sun, 25 Jan 2026 19:38:17 +0900 Subject: [PATCH 70/74] =?UTF-8?q?[SRLT-133]=20Refactor:=20Swagger=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=EB=A5=BC=20=EC=B5=9C=EC=8B=A0=ED=99=94?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../swagger/BackofficeExpertApiDoc.java | 171 +++++++++++++++--- .../webapi/BackofficeImageController.java | 6 +- .../webapi/swagger/BackofficeImageApiDoc.java | 80 +++++++- .../webapi/swagger/BackofficeMailApiDoc.java | 117 +++++++++++- 4 files changed, 331 insertions(+), 43 deletions(-) diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/swagger/BackofficeExpertApiDoc.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/swagger/BackofficeExpertApiDoc.java index 136e93d..dd0310e 100644 --- a/src/main/java/starlight/adapter/backoffice/expert/webapi/swagger/BackofficeExpertApiDoc.java +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/swagger/BackofficeExpertApiDoc.java @@ -39,6 +39,38 @@ public interface BackofficeExpertApiDoc { content = @Content( array = @ArraySchema(schema = @Schema(implementation = BackofficeExpertListResponse.class)) ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "조회 오류", + content = @Content(examples = { + @ExampleObject( + name = "전문가 조회 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_QUERY_ERROR", + "message": "전문가 정보를 조회하는 중에 오류가 발생했습니다." + } + } + """ + ), + @ExampleObject( + name = "신청 건수 조회 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_APPLICATION_QUERY_ERROR", + "message": "전문가 신청 정보를 조회하는 중에 오류가 발생했습니다." + } + } + """ + ) + }) ) }) @GetMapping @@ -89,29 +121,46 @@ ApiResponse create( @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "404", description = "전문가 조회 실패", + content = @Content(examples = @ExampleObject( + name = "전문가 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_NOT_FOUND", + "message": "해당 전문가를 찾을 수 없습니다." + } + } + """ + )) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "조회 오류", content = @Content(examples = { @ExampleObject( - name = "전문가 없음", + name = "전문가 조회 오류", value = """ { "result": "ERROR", "data": null, "error": { - "code": "EXPERT_NOT_FOUND", - "message": "해당 전문가를 찾을 수 없습니다." + "code": "EXPERT_QUERY_ERROR", + "message": "전문가 정보를 조회하는 중에 오류가 발생했습니다." } } """ ), @ExampleObject( - name = "조회 오류", + name = "신청 건수 조회 오류", value = """ { "result": "ERROR", "data": null, "error": { - "code": "EXPERT_QUERY_ERROR", - "message": "전문가 정보를 조회하는 중에 오류가 발생했습니다." + "code": "EXPERT_APPLICATION_QUERY_ERROR", + "message": "전문가 신청 정보를 조회하는 중에 오류가 발생했습니다." } } """ @@ -136,50 +185,68 @@ ApiResponse detail( @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "400", description = "요청 값 오류", - content = @Content(examples = @ExampleObject( - value = """ - { - "result": "ERROR", - "data": null, - "error": { - "code": "INVALID_REQUEST_ARGUMENT", - "message": "잘못된 요청 인자입니다." - } - } - """ - )) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "404", - description = "전문가 조회 실패", content = @Content(examples = { @ExampleObject( - name = "전문가 없음", + name = "요청 값 오류", value = """ { "result": "ERROR", "data": null, "error": { - "code": "EXPERT_NOT_FOUND", - "message": "해당 전문가를 찾을 수 없습니다." + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." } } """ ), @ExampleObject( - name = "조회 오류", + name = "경력 정보 오류", value = """ { "result": "ERROR", "data": null, "error": { - "code": "EXPERT_QUERY_ERROR", - "message": "전문가 정보를 조회하는 중에 오류가 발생했습니다." + "code": "EXPERT_CAREER_INVALID", + "message": "경력 정보가 올바르지 않습니다." } } """ ) }) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "전문가 조회 실패", + content = @Content(examples = @ExampleObject( + name = "전문가 없음", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_NOT_FOUND", + "message": "해당 전문가를 찾을 수 없습니다." + } + } + """ + )) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "조회 오류", + content = @Content(examples = @ExampleObject( + name = "전문가 조회 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_QUERY_ERROR", + "message": "전문가 정보를 조회하는 중에 오류가 발생했습니다." + } + } + """ + )) ) }) @PatchMapping("/{expertId}") @@ -196,6 +263,38 @@ ApiResponse update( @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", description = "성공" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + description = "요청 값 오류", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + )) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "전문가 조회 실패", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_NOT_FOUND", + "message": "해당 전문가를 찾을 수 없습니다." + } + } + """ + )) ) }) @PatchMapping("/{expertId}/active-status") @@ -228,6 +327,22 @@ ApiResponse updateActiveStatus( } """ )) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "전문가 조회 실패", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "EXPERT_NOT_FOUND", + "message": "해당 전문가를 찾을 수 없습니다." + } + } + """ + )) ) }) @PatchMapping("/{expertId}/profile-image") 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 5d4c233..3fcce8b 100644 --- a/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java @@ -1,7 +1,6 @@ package starlight.adapter.backoffice.image.webapi; import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.validation.annotation.Validated; @@ -14,7 +13,6 @@ import starlight.adapter.aireport.infrastructure.storage.NcpPresignedUrlProvider; 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.shared.apiPayload.response.ApiResponse; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; @@ -31,14 +29,14 @@ public class BackofficeImageController implements BackofficeImageApiDoc { @GetMapping(value = "/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) public ApiResponse getPresignedUrl( - @RequestParam @ValidImageFileName String fileName + @RequestParam String fileName ) { return ApiResponse.success(presignedUrlProvider.getPreSignedUrl(BACKOFFICE_USER_ID, fileName)); } @PostMapping(value = "/upload-url/public", consumes = MediaType.APPLICATION_JSON_VALUE) public ApiResponse finalizePublic( - @Valid @RequestBody BackofficeImagePublicRequest request + @RequestBody BackofficeImagePublicRequest request ) { return ApiResponse.success(presignedUrlProvider.makePublic(request.objectUrl())); } 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 68f6d47..4d44673 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 @@ -1,5 +1,6 @@ package starlight.adapter.backoffice.image.webapi.swagger; +import jakarta.validation.Valid; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; @@ -13,6 +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.shared.apiPayload.response.ApiResponse; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; @@ -33,15 +35,47 @@ public interface BackofficeImageApiDoc { ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "400", - description = "fileName 검증 실패", + description = "요청 값 오류", + content = @Content(examples = { + @ExampleObject( + name = "fileName 검증 실패", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "fileName이 올바르지 않습니다." + } + } + """ + ), + @ExampleObject( + name = "요청 값 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + ) + }) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "Presigned URL 생성 실패", content = @Content(examples = @ExampleObject( value = """ { "result": "ERROR", "data": null, "error": { - "code": "INVALID_REQUEST_ARGUMENT", - "message": "fileName이 올바르지 않습니다." + "code": "INTERNAL_ERROR", + "message": "알 수 없는 내부 오류입니다." } } """ @@ -50,7 +84,7 @@ public interface BackofficeImageApiDoc { }) @GetMapping(value = "/v1/backoffice/images/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) ApiResponse getPresignedUrl( - @RequestParam String fileName + @RequestParam @ValidImageFileName String fileName ); @Operation( @@ -65,14 +99,46 @@ ApiResponse getPresignedUrl( @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "400", description = "요청 값 오류", + content = @Content(examples = { + @ExampleObject( + name = "요청 값 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + ), + @ExampleObject( + name = "JSON 형식 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 JSON 형식입니다." + } + } + """ + ) + }) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "이미지 공개 처리 실패", content = @Content(examples = @ExampleObject( value = """ { "result": "ERROR", "data": null, "error": { - "code": "INVALID_REQUEST_ARGUMENT", - "message": "잘못된 요청 인자입니다." + "code": "INTERNAL_ERROR", + "message": "알 수 없는 내부 오류입니다." } } """ @@ -81,6 +147,6 @@ ApiResponse getPresignedUrl( }) @PostMapping(value = "/v1/backoffice/images/upload-url/public", consumes = MediaType.APPLICATION_JSON_VALUE) ApiResponse finalizePublic( - @RequestBody BackofficeImagePublicRequest request + @Valid @RequestBody BackofficeImagePublicRequest request ); } diff --git a/src/main/java/starlight/adapter/backoffice/mail/webapi/swagger/BackofficeMailApiDoc.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/swagger/BackofficeMailApiDoc.java index be24f35..766fe3a 100644 --- a/src/main/java/starlight/adapter/backoffice/mail/webapi/swagger/BackofficeMailApiDoc.java +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/swagger/BackofficeMailApiDoc.java @@ -44,14 +44,59 @@ public interface BackofficeMailApiDoc { @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "400", description = "요청 값 오류", + content = @Content(examples = { + @ExampleObject( + name = "요청 값 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + ), + @ExampleObject( + name = "contentType 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_MAIL_CONTENT_TYPE", + "message": "유효하지 않은 contentType입니다." + } + } + """ + ), + @ExampleObject( + name = "메일 요청 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_MAIL_REQUEST", + "message": "메일 발송 요청이 유효하지 않습니다." + } + } + """ + ) + }) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "메일 전송 실패", content = @Content(examples = @ExampleObject( value = """ { "result": "ERROR", "data": null, "error": { - "code": "INVALID_REQUEST_ARGUMENT", - "message": "잘못된 요청 인자입니다." + "code": "MAIL_SEND_FAILED", + "message": "메일 전송에 실패했습니다." } } """ @@ -76,14 +121,46 @@ ApiResponse send( @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "400", description = "요청 값 오류", + content = @Content(examples = { + @ExampleObject( + name = "요청 값 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + ), + @ExampleObject( + name = "contentType 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_MAIL_CONTENT_TYPE", + "message": "유효하지 않은 contentType입니다." + } + } + """ + ) + }) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "템플릿 저장 실패", content = @Content(examples = @ExampleObject( value = """ { "result": "ERROR", "data": null, "error": { - "code": "INVALID_REQUEST_ARGUMENT", - "message": "잘못된 요청 인자입니다." + "code": "MAIL_TEMPLATE_SAVE_FAILED", + "message": "메일 템플릿 저장에 실패했습니다." } } """ @@ -104,6 +181,22 @@ ApiResponse createTemplate( responseCode = "200", description = "성공", content = @Content(array = @ArraySchema(schema = @Schema(implementation = BackofficeMailTemplateResponse.class))) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "템플릿 조회 실패", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "MAIL_TEMPLATE_QUERY_FAILED", + "message": "메일 템플릿 조회에 실패했습니다." + } + } + """ + )) ) }) @GetMapping("/v1/backoffice/mail/templates") @@ -126,6 +219,22 @@ ApiResponse createTemplate( } """ )) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "500", + description = "템플릿 삭제 실패", + content = @Content(examples = @ExampleObject( + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "MAIL_TEMPLATE_DELETE_FAILED", + "message": "메일 템플릿 삭제에 실패했습니다." + } + } + """ + )) ) }) @DeleteMapping("/v1/backoffice/mail/templates/{templateId}") From 9fa71542cea5b3c7b8a5076bcc332b6b02046b43 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Mon, 26 Jan 2026 15:15:41 +0900 Subject: [PATCH 71/74] =?UTF-8?q?[SRLT-133]=20Refactor:=20ExpertApplicatio?= =?UTF-8?q?nJpa=20=ED=81=B4=EB=9E=98=EC=8A=A4=EB=AA=85=EC=9D=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../expertApplication/persistence/ExpertApplicationJpa.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java index 0f974f1..71896ab 100644 --- a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java +++ b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java @@ -16,7 +16,7 @@ @Slf4j @Component @RequiredArgsConstructor -public class ExpertApplicationJpaPort implements ExpertApplicationQueryPort, +public class ExpertApplicationJpa implements ExpertApplicationQueryPort, starlight.application.backoffice.expert.required.BackofficeExpertApplicationCountLookupPort, starlight.application.expert.required.ExpertApplicationCountLookupPort, starlight.application.expertReport.required.ExpertApplicationCountLookupPort { From fb9942c16e7a570e0442407e8516c40887c7fc6c Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Mon, 16 Feb 2026 00:04:05 +0900 Subject: [PATCH 72/74] =?UTF-8?q?[SRLT-133]=20Refactor:=20=EC=96=B4?= =?UTF-8?q?=EB=8C=91=ED=84=B0=20=EA=B3=84=EC=B8=B5=EC=9D=98=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20=EB=A1=9C=EC=A7=81=EC=9D=84=20shared=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=EB=A1=9C=20=EB=B6=84=EB=A6=AC=ED=95=9C?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backoffice/image/webapi/BackofficeImageController.java | 2 +- .../infrastructure/storage/NcpPresignedUrlProvider.java | 2 +- .../infrastructure/storage/NcpPresignedUrlProviderUnitTest.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) rename src/main/java/starlight/adapter/{aireport => shared}/infrastructure/storage/NcpPresignedUrlProvider.java (98%) 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 3fcce8b..42af7d6 100644 --- a/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java @@ -10,7 +10,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import starlight.adapter.aireport.infrastructure.storage.NcpPresignedUrlProvider; +import starlight.adapter.shared.infrastructure.storage.NcpPresignedUrlProvider; import starlight.adapter.backoffice.image.webapi.dto.request.BackofficeImagePublicRequest; import starlight.adapter.backoffice.image.webapi.swagger.BackofficeImageApiDoc; import starlight.shared.apiPayload.response.ApiResponse; diff --git a/src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java b/src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java similarity index 98% rename from src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java rename to src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java index 188211a..0360597 100644 --- a/src/main/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java +++ b/src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java @@ -1,4 +1,4 @@ -package starlight.adapter.aireport.infrastructure.storage; +package starlight.adapter.shared.infrastructure.storage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java b/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java index 22344d1..fe70868 100644 --- a/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java +++ b/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java @@ -14,7 +14,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.adapter.aireport.infrastructure.storage.NcpPresignedUrlProvider; +import starlight.adapter.shared.infrastructure.storage.NcpPresignedUrlProvider; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; import java.net.URL; From 08f2a215851323a836e8039bf122d807625a4ccb Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Mon, 16 Feb 2026 00:30:22 +0900 Subject: [PATCH 73/74] =?UTF-8?q?[SRLT-133]=20Refactor:=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EB=9E=98=EB=B9=97=20=EB=A6=AC=EB=B7=B0=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: seongho5356 --- .../request/BackofficeExpertUpdateRequest.java | 2 +- .../webapi/BackofficeImageController.java | 10 ++++++---- .../webapi/swagger/BackofficeImageApiDoc.java | 18 ++++++++++++++++-- .../persistence/BusinessPlanQueryJpa.java | 4 ++-- .../adapter/member/persistence/MemberJpa.java | 4 ++-- .../storage/NcpPresignedUrlProvider.java | 4 +++- .../application/aireport/AiReportService.java | 14 +++++++------- ...java => BusinessPlanCommandLookupPort.java} | 2 +- ...t.java => BusinessPlanQueryLookupPort.java} | 4 ++-- .../expert/BackofficeExpertCommandService.java | 4 +++- .../BackofficeMailSendLogEventHandler.java | 3 +-- .../businessplan/BusinessPlanService.java | 6 +++--- ...erLookUpPort.java => MemberLookupPort.java} | 2 +- .../expert/ExpertDetailQueryService.java | 2 +- .../aireport/exception/AiReportErrorType.java | 1 + .../starlight/domain/expert/entity/Expert.java | 2 +- .../expert/exception/ExpertErrorType.java | 1 + ...234\352\260\200\354\235\264\353\223\234.md" | 2 ++ 18 files changed, 54 insertions(+), 31 deletions(-) rename src/main/java/starlight/application/aireport/required/{BusinessPlanCommandLookUpPort.java => BusinessPlanCommandLookupPort.java} (82%) rename src/main/java/starlight/application/aireport/required/{BusinessPlanQueryLookUpPort.java => BusinessPlanQueryLookupPort.java} (75%) rename src/main/java/starlight/application/businessplan/required/{MemberLookUpPort.java => MemberLookupPort.java} (79%) diff --git a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertUpdateRequest.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertUpdateRequest.java index 740952f..f8df752 100644 --- a/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertUpdateRequest.java +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/request/BackofficeExpertUpdateRequest.java @@ -22,7 +22,7 @@ public record BackofficeExpertUpdateRequest( ) { public BackofficeExpertUpdateInput toInput(Long expertId) { List careerInputs = careers == null - ? List.of() + ? null : careers.stream() .map(career -> new BackofficeExpertCareerUpdateInput( career.id(), 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 42af7d6..56ff7b4 100644 --- a/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/BackofficeImageController.java @@ -1,6 +1,7 @@ package starlight.adapter.backoffice.image.webapi; import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.validation.annotation.Validated; @@ -10,9 +11,10 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -import starlight.adapter.shared.infrastructure.storage.NcpPresignedUrlProvider; 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.shared.apiPayload.response.ApiResponse; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; @@ -25,18 +27,18 @@ public class BackofficeImageController implements BackofficeImageApiDoc { private static final long BACKOFFICE_USER_ID = 0L; - private final NcpPresignedUrlProvider presignedUrlProvider; + private final PresignedUrlProviderPort presignedUrlProvider; @GetMapping(value = "/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) public ApiResponse getPresignedUrl( - @RequestParam String fileName + @RequestParam @ValidImageFileName String fileName ) { return ApiResponse.success(presignedUrlProvider.getPreSignedUrl(BACKOFFICE_USER_ID, fileName)); } @PostMapping(value = "/upload-url/public", consumes = MediaType.APPLICATION_JSON_VALUE) public ApiResponse finalizePublic( - @RequestBody BackofficeImagePublicRequest request + @Valid @RequestBody BackofficeImagePublicRequest request ) { return ApiResponse.success(presignedUrlProvider.makePublic(request.objectUrl())); } 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 4d44673..32c220d 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 @@ -30,7 +30,8 @@ public interface BackofficeImageApiDoc { responseCode = "200", description = "성공", content = @Content( - schema = @Schema(implementation = PreSignedUrlResponse.class) + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ApiResponse.class) ) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( @@ -94,7 +95,20 @@ ApiResponse getPresignedUrl( @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "200", - description = "성공" + description = "성공", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ApiResponse.class), + examples = @ExampleObject( + value = """ + { + "result": "SUCCESS", + "data": "https://bucket.example.com/path/to/object.jpg", + "error": null + } + """ + ) + ) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "400", diff --git a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanQueryJpa.java b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanQueryJpa.java index f5dd0d6..09a5eed 100644 --- a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanQueryJpa.java +++ b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanQueryJpa.java @@ -16,8 +16,8 @@ @RequiredArgsConstructor public class BusinessPlanQueryJpa implements BusinessPlanCommandPort, BusinessPlanQueryPort, starlight.application.expert.required.BusinessPlanQueryLookupPort, - starlight.application.aireport.required.BusinessPlanCommandLookUpPort, - starlight.application.aireport.required.BusinessPlanQueryLookUpPort { + starlight.application.aireport.required.BusinessPlanCommandLookupPort, + starlight.application.aireport.required.BusinessPlanQueryLookupPort { private final BusinessPlanRepository businessPlanRepository; diff --git a/src/main/java/starlight/adapter/member/persistence/MemberJpa.java b/src/main/java/starlight/adapter/member/persistence/MemberJpa.java index 76ec363..4ffc200 100644 --- a/src/main/java/starlight/adapter/member/persistence/MemberJpa.java +++ b/src/main/java/starlight/adapter/member/persistence/MemberJpa.java @@ -2,7 +2,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; -import starlight.application.businessplan.required.MemberLookUpPort; +import starlight.application.businessplan.required.MemberLookupPort; import starlight.application.member.required.MemberCommandPort; import starlight.application.member.required.MemberQueryPort; import starlight.domain.member.entity.Member; @@ -13,7 +13,7 @@ @Repository @RequiredArgsConstructor -public class MemberJpa implements MemberQueryPort, MemberCommandPort, MemberLookUpPort { +public class MemberJpa implements MemberQueryPort, MemberCommandPort, MemberLookupPort { private final MemberRepository memberRepository; 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 0360597..06de310 100644 --- a/src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java +++ b/src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java @@ -13,6 +13,8 @@ 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; import java.net.URLEncoder; @@ -84,7 +86,7 @@ public String makePublic(String objectUrl) { log.info("객체 공개 처리 완료(PUBLIC_READ): key={}", objectUrl); } catch (S3Exception e) { log.error("객체 공개 처리 실패 - Message: {}", e.getMessage()); - throw new RuntimeException("객체 공개 처리 실패: " + e.getMessage(), e); + throw new AiReportException(AiReportErrorType.OBJECT_ACL_UPDATE_FAILED, e); } return objectUrl; diff --git a/src/main/java/starlight/application/aireport/AiReportService.java b/src/main/java/starlight/application/aireport/AiReportService.java index f7b73f5..5de7c3b 100644 --- a/src/main/java/starlight/application/aireport/AiReportService.java +++ b/src/main/java/starlight/application/aireport/AiReportService.java @@ -28,8 +28,8 @@ @Transactional public class AiReportService implements AiReportUseCase { - private final BusinessPlanCommandLookUpPort businessPlanCommandLookUpPort; - private final BusinessPlanQueryLookUpPort businessPlanQueryLookUpPort; + private final BusinessPlanCommandLookupPort businessPlanCommandLookupPort; + private final BusinessPlanQueryLookupPort businessPlanQueryLookupPort; private final AiReportQueryPort aiReportQueryPort; private final AiReportCommandPort aiReportCommandPort; private final ReportGraderPort reportGrader; @@ -42,7 +42,7 @@ public class AiReportService implements AiReportUseCase { public AiReportResult gradeBusinessPlan(Long planId, Long memberId) { log.info("사업계획서 AI 채점 시작. planId: {}, memberId: {}", planId, memberId); - BusinessPlan plan = businessPlanQueryLookUpPort.findByIdOrThrow(planId); + BusinessPlan plan = businessPlanQueryLookupPort.findByIdOrThrow(planId); checkBusinessPlanOwned(plan, memberId); checkBusinessPlanWritingCompleted(plan); @@ -78,8 +78,8 @@ public AiReportResult gradeBusinessPlan(Long planId, Long memberId) { public AiReportResult createAndGradePdfBusinessPlan(String title, String pdfUrl, Long memberId) { log.info("PDF 사업계획서 생성 및 AI 채점 시작. title: {}, pdfUrl: {}, memberId: {}", title, pdfUrl, memberId); - Long businessPlanId = businessPlanCommandLookUpPort.createBusinessPlanWithPdf(title, pdfUrl, memberId); - BusinessPlan plan = businessPlanQueryLookUpPort.findByIdOrThrow(businessPlanId); + Long businessPlanId = businessPlanCommandLookupPort.createBusinessPlanWithPdf(title, pdfUrl, memberId); + BusinessPlan plan = businessPlanQueryLookupPort.findByIdOrThrow(businessPlanId); log.debug("OCR 시작. pdfUrl: {}", pdfUrl); String pdfText = ocrProvider.ocrPdfTextByUrl(pdfUrl); @@ -111,7 +111,7 @@ public AiReportResult createAndGradePdfBusinessPlan(String title, String pdfUrl, @Override @Transactional(readOnly = true) public AiReportResult getAiReport(Long planId, Long memberId) { - BusinessPlan plan = businessPlanQueryLookUpPort.findByIdOrThrow(planId); + BusinessPlan plan = businessPlanQueryLookupPort.findByIdOrThrow(planId); checkBusinessPlanOwned(plan, memberId); AiReport aiReport = aiReportQueryPort.findByBusinessPlanId(planId) @@ -143,7 +143,7 @@ private AiReport upsertAiReportWithRawJsonStr(String rawJsonString, BusinessPlan } plan.updateStatus(PlanStatus.AI_REVIEWED); - businessPlanCommandLookUpPort.save(plan); + businessPlanCommandLookupPort.save(plan); return aiReportCommandPort.save(aiReport); } diff --git a/src/main/java/starlight/application/aireport/required/BusinessPlanCommandLookUpPort.java b/src/main/java/starlight/application/aireport/required/BusinessPlanCommandLookupPort.java similarity index 82% rename from src/main/java/starlight/application/aireport/required/BusinessPlanCommandLookUpPort.java rename to src/main/java/starlight/application/aireport/required/BusinessPlanCommandLookupPort.java index 0c25cef..66a9dfb 100644 --- a/src/main/java/starlight/application/aireport/required/BusinessPlanCommandLookUpPort.java +++ b/src/main/java/starlight/application/aireport/required/BusinessPlanCommandLookupPort.java @@ -2,7 +2,7 @@ import starlight.domain.businessplan.entity.BusinessPlan; -public interface BusinessPlanCommandLookUpPort { +public interface BusinessPlanCommandLookupPort { BusinessPlan save(BusinessPlan plan); Long createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId); diff --git a/src/main/java/starlight/application/aireport/required/BusinessPlanQueryLookUpPort.java b/src/main/java/starlight/application/aireport/required/BusinessPlanQueryLookupPort.java similarity index 75% rename from src/main/java/starlight/application/aireport/required/BusinessPlanQueryLookUpPort.java rename to src/main/java/starlight/application/aireport/required/BusinessPlanQueryLookupPort.java index 0024cb7..f0ea03a 100644 --- a/src/main/java/starlight/application/aireport/required/BusinessPlanQueryLookUpPort.java +++ b/src/main/java/starlight/application/aireport/required/BusinessPlanQueryLookupPort.java @@ -2,6 +2,6 @@ import starlight.domain.businessplan.entity.BusinessPlan; -public interface BusinessPlanQueryLookUpPort { +public interface BusinessPlanQueryLookupPort { BusinessPlan findByIdOrThrow(Long id); -} \ No newline at end of file +} diff --git a/src/main/java/starlight/application/backoffice/expert/BackofficeExpertCommandService.java b/src/main/java/starlight/application/backoffice/expert/BackofficeExpertCommandService.java index faea25f..0c680d4 100644 --- a/src/main/java/starlight/application/backoffice/expert/BackofficeExpertCommandService.java +++ b/src/main/java/starlight/application/backoffice/expert/BackofficeExpertCommandService.java @@ -56,7 +56,9 @@ public void updateExpert(BackofficeExpertUpdateInput input) { expert.replaceTags(input.tags()); expert.replaceCategories(input.categories()); - expert.syncCareers(toCareerUpdates(input.careers())); + if (input.careers() != null) { + expert.syncCareers(toCareerUpdates(input.careers())); + } } @Override diff --git a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java index 3b09ddc..40620c7 100644 --- a/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java @@ -2,7 +2,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.EventListener; import org.springframework.dao.DataAccessException; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; @@ -21,7 +20,7 @@ public class BackofficeMailSendLogEventHandler { private final BackofficeMailSendLogCommandPort logCommandPort; @Async("emailTaskExecutor") - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION) public void handle(BackofficeMailSendEvent event) { String recipients = EmailMaskingUtils.maskRecipients(event.to()); diff --git a/src/main/java/starlight/application/businessplan/BusinessPlanService.java b/src/main/java/starlight/application/businessplan/BusinessPlanService.java index dc5a964..8675083 100644 --- a/src/main/java/starlight/application/businessplan/BusinessPlanService.java +++ b/src/main/java/starlight/application/businessplan/BusinessPlanService.java @@ -15,7 +15,7 @@ import starlight.application.businessplan.required.BusinessPlanCommandPort; import starlight.application.businessplan.required.BusinessPlanQueryPort; import starlight.application.businessplan.required.ChecklistGraderPort; -import starlight.application.businessplan.required.MemberLookUpPort; +import starlight.application.businessplan.required.MemberLookupPort; import starlight.application.businessplan.util.PlainTextExtractUtils; import starlight.application.businessplan.util.SubSectionSupportUtils; import starlight.domain.businessplan.entity.*; @@ -37,13 +37,13 @@ public class BusinessPlanService implements BusinessPlanUseCase { private final BusinessPlanCommandPort businessPlanCommandPort; private final BusinessPlanQueryPort businessPlanQueryPort; - private final MemberLookUpPort memberLookUpPort; + private final MemberLookupPort memberLookupPort; private final ChecklistGraderPort checklistGrader; private final ObjectMapper objectMapper; @Override public BusinessPlanResult.Result createBusinessPlan(Long memberId) { - Member member = memberLookUpPort.findByIdOrThrow(memberId); + Member member = memberLookupPort.findByIdOrThrow(memberId); String planTitle = member.getName() == null ? "제목 없는 사업계획서" : member.getName() + "의 사업계획서"; diff --git a/src/main/java/starlight/application/businessplan/required/MemberLookUpPort.java b/src/main/java/starlight/application/businessplan/required/MemberLookupPort.java similarity index 79% rename from src/main/java/starlight/application/businessplan/required/MemberLookUpPort.java rename to src/main/java/starlight/application/businessplan/required/MemberLookupPort.java index 5444524..978d72a 100644 --- a/src/main/java/starlight/application/businessplan/required/MemberLookUpPort.java +++ b/src/main/java/starlight/application/businessplan/required/MemberLookupPort.java @@ -2,6 +2,6 @@ import starlight.domain.member.entity.Member; -public interface MemberLookUpPort { +public interface MemberLookupPort { Member findByIdOrThrow(Long id); } diff --git a/src/main/java/starlight/application/expert/ExpertDetailQueryService.java b/src/main/java/starlight/application/expert/ExpertDetailQueryService.java index 1625e4d..3d20a8b 100644 --- a/src/main/java/starlight/application/expert/ExpertDetailQueryService.java +++ b/src/main/java/starlight/application/expert/ExpertDetailQueryService.java @@ -46,7 +46,7 @@ public List searchAllActive() { public ExpertDetailResult findById(Long expertId) { Expert expert = expertQueryPort.findByIdWithCareersAndTags(expertId); if (expert.getActiveStatus() != ExpertActiveStatus.ACTIVE) { - throw new ExpertException(ExpertErrorType.EXPERT_NOT_FOUND); + throw new ExpertException(ExpertErrorType.EXPERT_NOT_ACTIVE); } Map countMap = expertApplicationLookupPort.countByExpertIds(List.of(expertId)); long count = countMap.getOrDefault(expertId, 0L); diff --git a/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java b/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java index 56353ae..912baf9 100644 --- a/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java +++ b/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java @@ -14,6 +14,7 @@ public enum AiReportErrorType implements ErrorType { UNAUTHORIZED_ACCESS(HttpStatus.FORBIDDEN, "권한이 없습니다."), AI_RESPONSE_PARSING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AI 응답 파싱에 실패했습니다."), AI_GRADING_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "AI 채점에 실패했습니다."), + OBJECT_ACL_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "객체 공개 처리에 실패했습니다."), AI_AGENT_DUPLICATED(HttpStatus.INTERNAL_SERVER_ERROR, "AI 리포트 에이전트가 중복입니다."); ; diff --git a/src/main/java/starlight/domain/expert/entity/Expert.java b/src/main/java/starlight/domain/expert/entity/Expert.java index 2107249..10fc42a 100644 --- a/src/main/java/starlight/domain/expert/entity/Expert.java +++ b/src/main/java/starlight/domain/expert/entity/Expert.java @@ -173,7 +173,7 @@ public void syncCareers(List updates) { ExpertCareer career = careerById.get(update.id()); if (career == null) { - throw new ExpertException(ExpertErrorType.EXPERT_NOT_FOUND); + throw new ExpertException(ExpertErrorType.EXPERT_CAREER_INVALID); } career.update( diff --git a/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java b/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java index 144815a..4453c73 100644 --- a/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java +++ b/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java @@ -11,6 +11,7 @@ public enum ExpertErrorType implements ErrorType { EXPERT_QUERY_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "전문가 정보를 조회하는 중에 오류가 발생했습니다."), EXPERT_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 전문가를 찾을 수 없습니다."), + EXPERT_NOT_ACTIVE(HttpStatus.FORBIDDEN, "비활성 전문가입니다."), EXPERT_CAREER_INVALID(HttpStatus.BAD_REQUEST, "경력 정보가 올바르지 않습니다."); ; 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 de2440f..f45704e 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" @@ -30,6 +30,7 @@ - Inbound `provided`는 해당 도메인의 유스케이스만 노출한다. - Outbound 포트는 소비자 도메인에서 정의한다(`application//required`). - Cross-domain 조회는 `OtherDomainLookupPort` 규칙을 따른다. +- 포트/타입 네이밍에서 `Lookup`을 한 단어로 사용한다(`LookupPort`). `LookUp` 표기는 사용하지 않는다. - 다른 도메인의 `provided` 서비스를 직접 호출하지 않는다. 소비자 도메인에 `required` 포트를 정의하고, 제공 도메인의 어댑터가 구현한다. - Response DTO는 애플리케이션 DTO로만 변환하고 엔티티를 직접 받지 않는다. - 도메인 의미가 있는 포트는 소비자 도메인 `required`에 둔다. @@ -39,6 +40,7 @@ - Provided (inbound): `*UseCase` - Required (outbound): `*Port` - Cross-domain lookup: `OtherDomainLookupPort` +- Lookup 철자: `Lookup`만 사용 (`LookUp` 금지) - 컬렉션을 함께 로딩하는 경우 이름에 컬렉션을 명시한다. - 예: `findAllWithCareersTagsCategories`, `findByIdWithCareersAndTags` - 예: `fetchExpertsWithCareersByIds` From a9cacfea916ba47ab6284e271ff26ffa30bed741 Mon Sep 17 00:00:00 2001 From: seongho5356 Date: Mon, 16 Feb 2026 00:36:40 +0900 Subject: [PATCH 74/74] =?UTF-8?q?[SRLT-133]=20Fix:=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=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 Signed-off-by: seongho5356 --- .../NcpPresignedUrlProviderUnitTest.java | 7 ++-- .../AiReportServiceIntegrationTest.java | 12 +++--- .../aireport/AiReportServiceUnitTest.java | 38 +++++++++---------- .../BusinessPlanServiceIntegrationTest.java | 6 +-- .../BusinessPlanServiceUnitTest.java | 8 ++-- 5 files changed, 36 insertions(+), 35 deletions(-) diff --git a/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java b/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java index fe70868..2b3d8c4 100644 --- a/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java +++ b/src/test/java/starlight/adapter/aireport/infrastructure/storage/NcpPresignedUrlProviderUnitTest.java @@ -15,6 +15,7 @@ import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest; import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest; import starlight.adapter.shared.infrastructure.storage.NcpPresignedUrlProvider; +import starlight.domain.aireport.exception.AiReportException; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; import java.net.URL; @@ -149,8 +150,8 @@ void makePublic_Failure_S3Exception() { // when & then assertThatThrownBy(() -> presignedUrlProvider.makePublic(objectUrl)) - .isInstanceOf(RuntimeException.class) - .hasMessageContaining("객체 공개 처리 실패"); + .isInstanceOf(AiReportException.class) + .hasMessageContaining("객체 공개 처리에 실패했습니다."); verify(ncpS3Client).putObjectAcl(any(PutObjectAclRequest.class)); } @@ -178,4 +179,4 @@ void makePublic_InvalidUrl_NoPath() { .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("path가 없습니다"); } -} \ No newline at end of file +} diff --git a/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java b/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java index d1da1f1..5de51af 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java @@ -22,8 +22,8 @@ import starlight.application.aireport.required.ReportGraderPort; import starlight.application.businessplan.required.BusinessPlanCommandPort; import starlight.application.businessplan.required.BusinessPlanQueryPort; -import starlight.application.aireport.required.BusinessPlanCommandLookUpPort; -import starlight.application.aireport.required.BusinessPlanQueryLookUpPort; +import starlight.application.aireport.required.BusinessPlanCommandLookupPort; +import starlight.application.aireport.required.BusinessPlanQueryLookupPort; import starlight.application.businessplan.util.BusinessPlanContentExtractor; import starlight.domain.aireport.entity.AiReport; import starlight.domain.businessplan.entity.BusinessPlan; @@ -193,8 +193,8 @@ BusinessPlanContentExtractor businessPlanContentExtractor() { } @Bean - BusinessPlanCommandLookUpPort businessPlanCommandLookUpPort(BusinessPlanRepository businessPlanRepository) { - return new BusinessPlanCommandLookUpPort() { + BusinessPlanCommandLookupPort businessPlanCommandLookupPort(BusinessPlanRepository businessPlanRepository) { + return new BusinessPlanCommandLookupPort() { @Override public BusinessPlan save(BusinessPlan plan) { return businessPlanRepository.save(plan); @@ -210,8 +210,8 @@ public Long createBusinessPlanWithPdf(String title, String pdfUrl, Long memberId } @Bean - BusinessPlanQueryLookUpPort businessPlanQueryLookUpPort(BusinessPlanRepository businessPlanRepository) { - return new BusinessPlanQueryLookUpPort() { + BusinessPlanQueryLookupPort businessPlanQueryLookupPort(BusinessPlanRepository businessPlanRepository) { + return new BusinessPlanQueryLookupPort() { @Override public BusinessPlan findByIdOrThrow(Long id) { return businessPlanRepository.findById(id) diff --git a/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java b/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java index 4529c9a..1cc0c19 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java @@ -9,8 +9,8 @@ import starlight.application.aireport.required.AiReportQueryPort; import starlight.application.aireport.required.AiReportCommandPort; import starlight.application.aireport.required.OcrProviderPort; -import starlight.application.aireport.required.BusinessPlanCommandLookUpPort; -import starlight.application.aireport.required.BusinessPlanQueryLookUpPort; +import starlight.application.aireport.required.BusinessPlanCommandLookupPort; +import starlight.application.aireport.required.BusinessPlanQueryLookupPort; import starlight.application.businessplan.util.BusinessPlanContentExtractor; import starlight.domain.aireport.entity.AiReport; import starlight.domain.aireport.exception.AiReportErrorType; @@ -33,8 +33,8 @@ @DisplayName("AiReportService 유닛 테스트") class AiReportServiceUnitTest { - private final BusinessPlanCommandLookUpPort businessPlanCommandLookUpPort = mock(BusinessPlanCommandLookUpPort.class); - private final BusinessPlanQueryLookUpPort businessPlanQueryLookUpPort = mock(BusinessPlanQueryLookUpPort.class); + private final BusinessPlanCommandLookupPort businessPlanCommandLookupPort = mock(BusinessPlanCommandLookupPort.class); + private final BusinessPlanQueryLookupPort businessPlanQueryLookupPort = mock(BusinessPlanQueryLookupPort.class); private final AiReportQueryPort aiReportQuery = mock(AiReportQueryPort.class); private final AiReportCommandPort aiReportCommand = mock(AiReportCommandPort.class); private final ReportGraderPort aiReportGrader = mock(ReportGraderPort.class); @@ -55,7 +55,7 @@ void gradeBusinessPlan_createsNewReport() { when(plan.getId()).thenReturn(planId); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(true); - when(businessPlanQueryLookUpPort.findByIdOrThrow(planId)).thenReturn(plan); + when(businessPlanQueryLookupPort.findByIdOrThrow(planId)).thenReturn(plan); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.empty()); String extractedContent = "사업계획서 내용"; @@ -92,9 +92,9 @@ void gradeBusinessPlan_createsNewReport() { when(savedReport.getBusinessPlanId()).thenReturn(planId); when(savedReport.getRawJson()).thenReturn(RawJson.create(rawJson)); when(aiReportCommand.save(any(AiReport.class))).thenReturn(savedReport); - when(businessPlanCommandLookUpPort.save(any(BusinessPlan.class))).thenReturn(plan); + 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, ocrProvider, responseParser, contentExtractor); // when AiReportResult result = sut.gradeBusinessPlan(planId, memberId); @@ -103,7 +103,7 @@ void gradeBusinessPlan_createsNewReport() { assertThat(result).isNotNull(); verify(plan).updateStatus(PlanStatus.AI_REVIEWED); verify(aiReportCommand).save(any(AiReport.class)); - verify(businessPlanCommandLookUpPort).save(plan); + verify(businessPlanCommandLookupPort).save(plan); } @Test @@ -116,7 +116,7 @@ void gradeBusinessPlan_updatesExistingReport() { when(plan.getId()).thenReturn(planId); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(true); - when(businessPlanQueryLookUpPort.findByIdOrThrow(planId)).thenReturn(plan); + when(businessPlanQueryLookupPort.findByIdOrThrow(planId)).thenReturn(plan); AiReport existingReport = mock(AiReport.class); when(aiReportQuery.findByBusinessPlanId(planId)).thenReturn(Optional.of(existingReport)); @@ -154,9 +154,9 @@ void gradeBusinessPlan_updatesExistingReport() { when(existingReport.getBusinessPlanId()).thenReturn(planId); when(existingReport.getRawJson()).thenReturn(RawJson.create(rawJson)); when(aiReportCommand.save(existingReport)).thenReturn(existingReport); - when(businessPlanCommandLookUpPort.save(any(BusinessPlan.class))).thenReturn(plan); + 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, ocrProvider, responseParser, contentExtractor); // when AiReportResult result = sut.gradeBusinessPlan(planId, memberId); @@ -176,9 +176,9 @@ void gradeBusinessPlan_throwsExceptionWhenNotOwner() { Long memberId = 1L; BusinessPlan plan = mock(BusinessPlan.class); when(plan.isOwnedBy(memberId)).thenReturn(false); - when(businessPlanQueryLookUpPort.findByIdOrThrow(planId)).thenReturn(plan); + 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, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) @@ -196,9 +196,9 @@ void gradeBusinessPlan_throwsExceptionWhenNotCompleted() { BusinessPlan plan = mock(BusinessPlan.class); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(false); - when(businessPlanQueryLookUpPort.findByIdOrThrow(planId)).thenReturn(plan); + 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, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) @@ -217,7 +217,7 @@ void getAiReport_returnsResponse() { when(plan.getId()).thenReturn(planId); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(true); - when(businessPlanQueryLookUpPort.findByIdOrThrow(planId)).thenReturn(plan); + when(businessPlanQueryLookupPort.findByIdOrThrow(planId)).thenReturn(plan); String rawJson = """ { @@ -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, ocrProvider, responseParser, contentExtractor); // when AiReportResult result = sut.getAiReport(planId, memberId); @@ -257,10 +257,10 @@ void getAiReport_throwsExceptionWhenNotFound() { when(plan.getId()).thenReturn(planId); when(plan.isOwnedBy(memberId)).thenReturn(true); when(plan.areWritingCompleted()).thenReturn(true); - when(businessPlanQueryLookUpPort.findByIdOrThrow(planId)).thenReturn(plan); + 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, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.getAiReport(planId, memberId)) diff --git a/src/test/java/starlight/application/businessplan/BusinessPlanServiceIntegrationTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceIntegrationTest.java index 6165694..7d951fa 100644 --- a/src/test/java/starlight/application/businessplan/BusinessPlanServiceIntegrationTest.java +++ b/src/test/java/starlight/application/businessplan/BusinessPlanServiceIntegrationTest.java @@ -12,7 +12,7 @@ import starlight.adapter.businessplan.persistence.BusinessPlanQueryJpa; import starlight.adapter.businessplan.persistence.BusinessPlanRepository; import starlight.application.businessplan.required.ChecklistGraderPort; -import starlight.application.businessplan.required.MemberLookUpPort; +import starlight.application.businessplan.required.MemberLookupPort; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.entity.SubSection; import starlight.domain.businessplan.enumerate.SubSectionType; @@ -50,8 +50,8 @@ ObjectMapper objectMapper() { } @Bean - MemberLookUpPort memberLookUpPort() { - return new MemberLookUpPort() { + MemberLookupPort memberLookupPort() { + return new MemberLookupPort() { @Override public Member findByIdOrThrow(Long memberId) { Member m = mock(Member.class); diff --git a/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java index 83b2571..3fc6c64 100644 --- a/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java +++ b/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java @@ -21,7 +21,7 @@ import starlight.domain.businessplan.enumerate.SubSectionType; import starlight.domain.businessplan.exception.BusinessPlanException; import starlight.shared.enumerate.SectionType; -import starlight.application.businessplan.required.MemberLookUpPort; +import starlight.application.businessplan.required.MemberLookupPort; import starlight.domain.member.entity.Member; import java.util.List; @@ -50,7 +50,7 @@ class BusinessPlanServiceUnitTest { private ObjectMapper objectMapper; @Mock - private MemberLookUpPort memberLookUpPort; + private MemberLookupPort memberLookupPort; @InjectMocks private BusinessPlanService sut; @@ -67,10 +67,10 @@ void setup() { when(objectMapper.writeValueAsString(any())).thenReturn("{}"); } catch (Exception ignored) { } - // memberLookUpPort 기본 스텁 + // memberLookupPort 기본 스텁 Member stubMember = mock(Member.class); when(stubMember.getName()).thenReturn("tester"); - when(memberLookUpPort.findByIdOrThrow(anyLong())).thenReturn(stubMember); + when(memberLookupPort.findByIdOrThrow(anyLong())).thenReturn(stubMember); } @Test