diff --git a/config b/config index 8cac261b..403ba835 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 8cac261b72556ebd4213d2d0590de18bf61e285d +Subproject commit 403ba8356845b8b8d9c0cab9ec8a47253d6bdf74 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 510d5181..00000000 --- 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/PromptProvider.java b/src/main/java/starlight/adapter/ai/infra/PromptProvider.java deleted file mode 100644 index 9b283363..00000000 --- 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 7d5c44b8..00000000 --- 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 86a5236e..00000000 --- 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/aireport/infrastructure/ocr/ClovaOcrProvider.java b/src/main/java/starlight/adapter/aireport/infrastructure/ocr/ClovaOcrProvider.java index d36d7709..5b318696 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/persistence/AiReportJpa.java b/src/main/java/starlight/adapter/aireport/persistence/AiReportJpa.java index a5513466..1627a735 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.application.aireport.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/report/SpringAiReportGrader.java b/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java new file mode 100644 index 00000000..fa1fe7b2 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/SpringAiReportGrader.java @@ -0,0 +1,242 @@ +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.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.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.*; +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 sectionGradeAgentMap; + private final FullReportGradeAgent fullReportGradeAgent; + private final SpringAiReportSupervisor supervisor; + private final BusinessPlanContentExtractor contentExtractor; + private final Executor sectionGradingExecutor; + + public SpringAiReportGrader( + List sectionGradeAgentList, + FullReportGradeAgent fullReportGradeAgent, + SpringAiReportSupervisor supervisor, + BusinessPlanContentExtractor contentExtractor, + @Qualifier("sectionGradingExecutor") Executor sectionGradingExecutor) { + try { + this.sectionGradeAgentMap = sectionGradeAgentList.stream() + .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; + 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(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개 섹션을 병렬로 채점 + 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분) + 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 { + if (future.isCancelled()) { + return SectionGradingResult.failure(sectionType, "타임아웃"); + } + return future.get(0, TimeUnit.SECONDS); + } catch (java.util.concurrent.TimeoutException e) { + return SectionGradingResult.failure(sectionType, "타임아웃"); + } catch (Exception 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); + } + + // 슈퍼바이저가 장단점 생성 + log.debug("슈퍼바이저 장단점 생성 시작"); + List strengths = supervisor.generateStrengths(fullContent, results); + List weaknesses = supervisor.generateWeaknesses(fullContent, results); + log.debug("슈퍼바이저 장단점 생성 완료. 강점: {}, 약점: {}", strengths.size(), weaknesses.size()); + + // 결과 통합 + 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) { + 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 00000000..134ce934 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/agent/FullReportGradeAgent.java @@ -0,0 +1,8 @@ +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 new file mode 100644 index 00000000..2119e18f --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/agent/SectionGradeAgent.java @@ -0,0 +1,14 @@ +package starlight.adapter.aireport.report.agent; + +import starlight.adapter.aireport.report.dto.SectionGradingResult; +import starlight.shared.enumerate.SectionType; + +public interface SectionGradeAgent { + + SectionType getSectionType(); + + SectionGradingResult gradeSection(String sectionContent); +} + + + 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 00000000..01ca5479 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiFullReportGradeAgent.java @@ -0,0 +1,65 @@ +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.agent.FullReportGradeAgent; +import starlight.adapter.aireport.report.provider.SpringAiAdvisorProvider; +import starlight.adapter.aireport.report.provider.ReportPromptProvider; +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; + +@Slf4j +@Component +@RequiredArgsConstructor +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 new file mode 100644 index 00000000..a94619b4 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/agent/impl/SpringAiSectionGradeAgent.java @@ -0,0 +1,139 @@ +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 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.application.aireport.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 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 { + // 섹션별 응답 파싱 메소드 사용 + AiReportResult sectionResponse = responseParser.parseSectionResponse(llmResponse); + + // sectionScores에서 해당 섹션 찾기 + String sectionTypeString = getSectionType().name(); + AiReportResult.SectionScoreDetailResponse sectionScore = sectionResponse.sectionScores().stream() + .filter(ss -> sectionTypeString.equals(ss.sectionType())) + .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) { + log.error("[{}] 응답 파싱 실패", getSectionType(), e); + 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/report/circuitbreaker/SectionGradingCircuitBreaker.java b/src/main/java/starlight/adapter/aireport/report/circuitbreaker/SectionGradingCircuitBreaker.java new file mode 100644 index 00000000..a970d3a9 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/circuitbreaker/SectionGradingCircuitBreaker.java @@ -0,0 +1,124 @@ +package starlight.adapter.aireport.report.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); + failureCount.set(0); // HALF_OPEN 전환 시 failureCount 리셋 + lastFailureTime.set(LocalDateTime.now()); // lastFailureTime도 갱신 + 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) { + int failures = failureCount.incrementAndGet(); + lastFailureTime.set(LocalDateTime.now()); + + if (failures >= FAILURE_THRESHOLD) { + 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"); + } + } + } + } + + public boolean allowRequest(SectionType sectionType) { + CircuitState circuit = circuitStates.computeIfAbsent( + sectionType, + k -> new CircuitState() + ); + return circuit.allowRequest(); + } + + public void recordSuccess(SectionType sectionType) { + 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) { + recordFailure(sectionType, null); + } +} 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 00000000..117266e2 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/dto/SectionGradingResult.java @@ -0,0 +1,27 @@ +package starlight.adapter.aireport.report.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 00000000..3c590b70 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/provider/ReportPromptProvider.java @@ -0,0 +1,73 @@ +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.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import starlight.shared.enumerate.SectionType; + +import java.util.List; + +@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; + + /** + * 리포트 채점용 Prompt 객체 생성 + */ + public Prompt createReportGradingPrompt(String businessPlanContent) { + Message systemMessage = new SystemMessage(reportGradingSystemPrompt); + 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)); + } + + /** + * 섹션별 채점용 시스템 프롬프트 + * 공통 프롬프트와 섹션별 프롬프트를 합쳐서 반환 + */ + 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; + } +} diff --git a/src/main/java/starlight/adapter/ai/infra/AdvisorProvider.java b/src/main/java/starlight/adapter/aireport/report/provider/SpringAiAdvisorProvider.java similarity index 93% rename from src/main/java/starlight/adapter/ai/infra/AdvisorProvider.java rename to src/main/java/starlight/adapter/aireport/report/provider/SpringAiAdvisorProvider.java index 61e73481..16c742a8 100644 --- a/src/main/java/starlight/adapter/ai/infra/AdvisorProvider.java +++ b/src/main/java/starlight/adapter/aireport/report/provider/SpringAiAdvisorProvider.java @@ -1,4 +1,4 @@ -package starlight.adapter.ai.infra; +package starlight.adapter.aireport.report.provider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -12,7 +12,7 @@ @Service @Slf4j @RequiredArgsConstructor -public class AdvisorProvider { +public class SpringAiAdvisorProvider { private final VectorStore vectorStore; diff --git a/src/main/java/starlight/adapter/aireport/report/supervisor/SpringAiReportSupervisor.java b/src/main/java/starlight/adapter/aireport/report/supervisor/SpringAiReportSupervisor.java new file mode 100644 index 00000000..ca0178cd --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/report/supervisor/SpringAiReportSupervisor.java @@ -0,0 +1,106 @@ +package starlight.adapter.aireport.report.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.report.dto.SectionGradingResult; +import starlight.application.aireport.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 SpringAiReportSupervisor { + + 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.0) + .topP(0.1) + .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/webapi/AiReportController.java b/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java index e39411be..da85592c 100644 --- a/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java +++ b/src/main/java/starlight/adapter/aireport/webapi/AiReportController.java @@ -1,57 +1,56 @@ 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.businessplan.webapi.dto.BusinessPlanCreateWithPdfRequest; +import starlight.adapter.aireport.webapi.dto.AiReportCreateWithPdfRequest; +import starlight.adapter.aireport.webapi.dto.AiReportResponse; +import starlight.adapter.aireport.webapi.swagger.AiReportApiDoc; 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.AiReportUseCase; import starlight.shared.apiPayload.response.ApiResponse; @Validated @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 AiReportService aiReportService; + private final AiReportUseCase aiReportUseCase; - @Operation(summary = "사업계획서를 AI로 채점 및 생성합니다.") @PostMapping("/evaluation/{planId}") public ApiResponse gradeBusinessPlan( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId ) { - return ApiResponse.success(aiReportService.gradeBusinessPlan(planId, authDetails.getMemberId())); + return ApiResponse.success( + AiReportResponse.from(aiReportUseCase.gradeBusinessPlan(planId, authDetails.getMemberId())) + ); } - @Operation(summary = "PDF URL을 기반으로 사업계획서를 생성하고, AI로 채점 및 생성합니다.") @PostMapping("/evaluation/pdf") public ApiResponse createAndGradeBusinessPlan( @AuthenticationPrincipal AuthDetails authDetails, - @Valid @RequestBody BusinessPlanCreateWithPdfRequest request + @Valid @RequestBody AiReportCreateWithPdfRequest request ) { - return ApiResponse.success(aiReportService.createAndGradePdfBusinessPlan( - request.title(), - request.pdfUrl(), - authDetails.getMemberId() - )); + return ApiResponse.success( + AiReportResponse.from(aiReportUseCase.createAndGradePdfBusinessPlan( + request.title(), + request.pdfUrl(), + authDetails.getMemberId() + )) + ); } - @Operation(summary = "AI 리포트를 조회합니다.") @GetMapping("/{planId}") public ApiResponse getAiReport( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId ) { - return ApiResponse.success(aiReportService.getAiReport(planId, authDetails.getMemberId())); + return ApiResponse.success( + AiReportResponse.from(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 442d4993..19302ff0 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/aireport/webapi/dto/AiReportCreateWithPdfRequest.java b/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportCreateWithPdfRequest.java new file mode 100644 index 00000000..854c6582 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportCreateWithPdfRequest.java @@ -0,0 +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 +) {} 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 00000000..dd573b00 --- /dev/null +++ b/src/main/java/starlight/adapter/aireport/webapi/dto/AiReportResponse.java @@ -0,0 +1,66 @@ +package starlight.adapter.aireport.webapi.dto; + +import com.fasterxml.jackson.annotation.JsonRawValue; +import java.util.List; +import java.util.Objects; +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 sourceSectionScores = + Objects.requireNonNullElse(result.sectionScores(), List.of()); + List sectionScores = sourceSectionScores.stream() + .map(s -> new SectionScoreDetailResponse(s.sectionType(), s.gradingListScores())) + .toList(); + + List sourceStrengths = + Objects.requireNonNullElse(result.strengths(), List.of()); + List strengths = sourceStrengths.stream() + .map(s -> new StrengthWeakness(s.title(), s.content())) + .toList(); + + List sourceWeaknesses = + Objects.requireNonNullElse(result.weaknesses(), List.of()); + List weaknesses = sourceWeaknesses.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 new file mode 100644 index 00000000..437ae024 --- /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.aireport.webapi.dto.AiReportCreateWithPdfRequest; +import starlight.adapter.aireport.webapi.dto.AiReportResponse; +import starlight.adapter.member.auth.security.auth.AuthDetails; +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 = AiReportResponse.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 = AiReportResponse.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 AiReportCreateWithPdfRequest 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 = AiReportResponse.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/backoffice/expert/webapi/BackofficeExpertController.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/BackofficeExpertController.java new file mode 100644 index 00000000..da233e15 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/BackofficeExpertController.java @@ -0,0 +1,104 @@ +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; + +@RestController +@RequiredArgsConstructor +@SecurityRequirement(name = "backofficeSession") +@RequestMapping("/v1/backoffice/experts") +public class BackofficeExpertController implements BackofficeExpertApiDoc { + + private final BackofficeExpertQueryUseCase backofficeExpertQuery; + private final BackofficeExpertCommandUseCase backofficeExpertCommand; + + @GetMapping + public ApiResponse> 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 00000000..87518508 --- /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 00000000..15d1c113 --- /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 00000000..90619cfe --- /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 00000000..5cfdce32 --- /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 00000000..f8df7524 --- /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 + ? null + : 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 00000000..3086dafe --- /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 00000000..b507bb51 --- /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 00000000..620ca93a --- /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/dto/response/BackofficeExpertListResponse.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/dto/response/BackofficeExpertListResponse.java new file mode 100644 index 00000000..b9e3a18e --- /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/expert/webapi/swagger/BackofficeExpertApiDoc.java b/src/main/java/starlight/adapter/backoffice/expert/webapi/swagger/BackofficeExpertApiDoc.java new file mode 100644 index 00000000..dd0310e9 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/expert/webapi/swagger/BackofficeExpertApiDoc.java @@ -0,0 +1,384 @@ +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)) + ) + ), + @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 + 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": "해당 전문가를 찾을 수 없습니다." + } + } + """ + )) + ), + @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("/{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( + name = "요청 값 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "code": "INVALID_REQUEST_ARGUMENT", + "message": "잘못된 요청 인자입니다." + } + } + """ + ), + @ExampleObject( + name = "경력 정보 오류", + value = """ + { + "result": "ERROR", + "data": null, + "error": { + "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}") + 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 = "성공" + ), + @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") + 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": "잘못된 요청 인자입니다." + } + } + """ + )) + ), + @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") + 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 00000000..56ff7b47 --- /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.PresignedUrlProviderPort; +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 PresignedUrlProviderPort 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 00000000..fb96bf5e --- /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 00000000..32c220d8 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/image/webapi/swagger/BackofficeImageApiDoc.java @@ -0,0 +1,166 @@ +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; +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.adapter.backoffice.image.webapi.validation.ValidImageFileName; +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( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ApiResponse.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "400", + 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": "INTERNAL_ERROR", + "message": "알 수 없는 내부 오류입니다." + } + } + """ + )) + ) + }) + @GetMapping(value = "/v1/backoffice/images/upload-url", produces = MediaType.APPLICATION_JSON_VALUE) + ApiResponse getPresignedUrl( + @RequestParam @ValidImageFileName String fileName + ); + + @Operation( + summary = "이미지 공개 전환(백오피스)", + security = @SecurityRequirement(name = "backofficeSession") + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + 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", + 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": "INTERNAL_ERROR", + "message": "알 수 없는 내부 오류입니다." + } + } + """ + )) + ) + }) + @PostMapping(value = "/v1/backoffice/images/upload-url/public", consumes = MediaType.APPLICATION_JSON_VALUE) + ApiResponse finalizePublic( + @Valid @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 00000000..9727ada9 --- /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 00000000..a43ef885 --- /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/email/SmtpMailSender.java b/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java new file mode 100644 index 00000000..0eda1a13 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/email/SmtpMailSender.java @@ -0,0 +1,48 @@ +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.exception.BackofficeErrorType; +import starlight.domain.backoffice.exception.BackofficeException; +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 recipients={} subject={}", input.to().size(), input.subject()); + } catch (MessagingException 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/adapter/backoffice/mail/persistence/BackofficeMailSendLogJpa.java b/src/main/java/starlight/adapter/backoffice/mail/persistence/BackofficeMailSendLogJpa.java new file mode 100644 index 00000000..600e70e1 --- /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 00000000..b8b7dab3 --- /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 00000000..63e4b42c --- /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 00000000..0ecd52aa --- /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 00000000..e017521b --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/BackofficeMailController.java @@ -0,0 +1,55 @@ +package starlight.adapter.backoffice.mail.webapi; + +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +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.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; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@SecurityRequirement(name = "backofficeSession") +public class BackofficeMailController implements BackofficeMailApiDoc { + + private final BackofficeMailSendUseCase backofficeMailSendUseCase; + private final BackofficeMailTemplateUseCase templateUseCase; + + @PostMapping("/v1/backoffice/mail/send") + 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 + ) { + BackofficeMailTemplateResponse response = BackofficeMailTemplateResponse.from(templateUseCase.createTemplate(request.toInput())); + return ApiResponse.success(response); + } + + @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("템플릿이 삭제되었습니다."); + } +} 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 00000000..616df1cf --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailSendRequest.java @@ -0,0 +1,41 @@ +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 jakarta.validation.constraints.Pattern; +import org.springframework.util.StringUtils; +import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendInput; + +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") + @Pattern(regexp = "(?i)^(html|text)$", message = "contentType must be html or text") + String contentType, + 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/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 00000000..81974a64 --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/dto/request/BackofficeMailTemplateCreateRequest.java @@ -0,0 +1,18 @@ +package starlight.adapter.backoffice.mail.webapi.dto.request; + +import jakarta.validation.constraints.NotBlank; +import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailTemplateCreateInput; +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 BackofficeMailTemplateCreateInput.of(name, title, contentType, html, text); + } +} 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 00000000..c8d9533d --- /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/adapter/backoffice/mail/webapi/swagger/BackofficeMailApiDoc.java b/src/main/java/starlight/adapter/backoffice/mail/webapi/swagger/BackofficeMailApiDoc.java new file mode 100644 index 00000000..766fe3aa --- /dev/null +++ b/src/main/java/starlight/adapter/backoffice/mail/webapi/swagger/BackofficeMailApiDoc.java @@ -0,0 +1,244 @@ +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( + 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": "MAIL_SEND_FAILED", + "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( + 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": "MAIL_TEMPLATE_SAVE_FAILED", + "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))) + ), + @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") + 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 + } + """ + )) + ), + @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}") + ApiResponse deleteTemplate( + @PathVariable Long templateId + ); +} diff --git a/src/main/java/starlight/adapter/ai/OpenAiChecklistGrader.java b/src/main/java/starlight/adapter/businessplan/checklist/SpringAiChecklistGrader.java similarity index 75% rename from src/main/java/starlight/adapter/ai/OpenAiChecklistGrader.java rename to src/main/java/starlight/adapter/businessplan/checklist/SpringAiChecklistGrader.java index 6ea468e4..33b8b1f6 100644 --- a/src/main/java/starlight/adapter/ai/OpenAiChecklistGrader.java +++ b/src/main/java/starlight/adapter/businessplan/checklist/SpringAiChecklistGrader.java @@ -1,23 +1,23 @@ -package starlight.adapter.ai; +package starlight.adapter.businessplan.checklist; 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.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.ai.util.ChecklistCatalog; +import starlight.adapter.businessplan.checklist.provider.ChecklistPromptProvider; @Slf4j @Service @RequiredArgsConstructor -public class OpenAiChecklistGrader implements ChecklistGrader { +public class SpringAiChecklistGrader implements ChecklistGraderPort { - private final OpenAiGenerator generator; - private final ChecklistCatalog checklistCatalog; + private final SpringAiChecklistAgent generator; + private final ChecklistPromptProvider checklistCatalog; @Override public List check( diff --git a/src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java b/src/main/java/starlight/adapter/businessplan/checklist/agent/SpringAiChecklistAgent.java similarity index 57% rename from src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java rename to src/main/java/starlight/adapter/businessplan/checklist/agent/SpringAiChecklistAgent.java index c90ffb21..1e6c4162 100644 --- a/src/main/java/starlight/adapter/ai/infra/OpenAiGenerator.java +++ b/src/main/java/starlight/adapter/businessplan/checklist/agent/SpringAiChecklistAgent.java @@ -1,38 +1,37 @@ -package starlight.adapter.ai.infra; +package starlight.adapter.businessplan.checklist.agent; +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.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.adapter.businessplan.checklist.provider.ChecklistPromptProvider; +import starlight.adapter.aireport.report.provider.SpringAiAdvisorProvider; 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 { +public class SpringAiChecklistAgent { private final ChatClient.Builder chatClientBuilder; - private final PromptProvider promptProvider; - private final AdvisorProvider advisorProvider; - private final ObjectMapper objectMapper = new ObjectMapper(); + private final ChecklistPromptProvider checklistPromptProvider; + private final SpringAiAdvisorProvider advisorProvider; + private final ObjectMapper objectMapper; - @Override public List generateChecklistArray( SubSectionType subSectionType, String content, List criteria, List detailedCriteria ) { - Prompt prompt = promptProvider.createChecklistGradingPrompt( + Prompt prompt = checklistPromptProvider.createChecklistGradingPrompt( subSectionType, content, criteria, detailedCriteria ); @@ -57,24 +56,4 @@ public List generateChecklistArray( 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/businessplan/checklist/provider/ChecklistPromptProvider.java b/src/main/java/starlight/adapter/businessplan/checklist/provider/ChecklistPromptProvider.java new file mode 100644 index 00000000..42bf7a3a --- /dev/null +++ b/src/main/java/starlight/adapter/businessplan/checklist/provider/ChecklistPromptProvider.java @@ -0,0 +1,118 @@ +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; + +@Component +@ConfigurationProperties(prefix = "prompt.checklist") +@Getter +@Setter +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 { + 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()); + } + + /** + * 체크리스트 채점용 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); + } +} diff --git a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanQueryJpa.java similarity index 69% rename from src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java rename to src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanQueryJpa.java index 8966aef2..09a5eeda 100644 --- a/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanJpa.java +++ b/src/main/java/starlight/adapter/businessplan/persistence/BusinessPlanQueryJpa.java @@ -4,8 +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.expert.required.BusinessPlanLookupPort; +import starlight.application.businessplan.required.BusinessPlanCommandPort; +import starlight.application.businessplan.required.BusinessPlanQueryPort; import starlight.domain.businessplan.entity.BusinessPlan; import starlight.domain.businessplan.exception.BusinessPlanErrorType; import starlight.domain.businessplan.exception.BusinessPlanException; @@ -14,7 +14,10 @@ @Repository @RequiredArgsConstructor -public class BusinessPlanJpa implements BusinessPlanQuery, 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; @@ -26,7 +29,7 @@ public BusinessPlan findByIdOrThrow(Long id) { } @Override - public BusinessPlan getOrThrowWithAllSubSections(Long id) { + public BusinessPlan findByIdWithAllSubSectionsOrThrow(Long id) { return businessPlanRepository.findByIdWithAllSubSections(id).orElseThrow( () -> new BusinessPlanException(BusinessPlanErrorType.BUSINESS_PLAN_NOT_FOUND) ); @@ -51,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/adapter/businessplan/spellcheck/DaumSpellChecker.java b/src/main/java/starlight/adapter/businessplan/spellcheck/DaumSpellChecker.java index 875bd0d3..82ef67c2 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/adapter/businessplan/webapi/BusinessPlanController.java b/src/main/java/starlight/adapter/businessplan/webapi/BusinessPlanController.java index 50affe2c..28f60a97 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,9 +13,10 @@ 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.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; import starlight.domain.businessplan.enumerate.SubSectionType; import starlight.shared.apiPayload.response.ApiResponse; @@ -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 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 + @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,8 +45,7 @@ public ApiResponse getBusinessPlanList( } @GetMapping("/{planId}/subsections") - @Operation(summary = "사업 계획서의 제목과 모든 서브섹션 내용을 조회합니다. (미리보기 용)") - public ApiResponse getBusinessPlanDetail( + 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,16 +66,14 @@ public ApiResponse getBusinessPlanTitle( } @PostMapping - @Operation(summary = "사업 계획서를 생성합니다.") - public ApiResponse createBusinessPlan( + public ApiResponse createBusinessPlan( @AuthenticationPrincipal AuthDetails authDetails ) { return ApiResponse.success(businessPlanService.createBusinessPlan(authDetails.getMemberId())); } @PostMapping("/pdf") - @Operation(summary = "PDF URL을 기반으로 사업계획서를 생성합니다.") - public ApiResponse createBusinessPlanWithPdfAndAiReport( + public ApiResponse createBusinessPlanWithPdfAndAiReport( @AuthenticationPrincipal AuthDetails authDetails, @Valid @RequestBody BusinessPlanCreateWithPdfRequest request ) { @@ -93,7 +83,6 @@ public ApiResponse createBusinessPlanWithPdfAndAiRe } @PatchMapping("/{planId}") - @Operation(summary = "사업 계획서 제목을 수정합니다.") public ApiResponse updateBusinessPlanTitle( @AuthenticationPrincipal AuthDetails authDetails, @RequestBody @Valid BusinessPlanCreateRequest request, @@ -104,9 +93,8 @@ public ApiResponse updateBusinessPlanTitle( )); } - @Operation(summary = "사업 계획서를 삭제합니다.") @DeleteMapping("/{planId}") - public ApiResponse deleteBusinessPlan( + public ApiResponse deleteBusinessPlan( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId ) { @@ -115,9 +103,8 @@ public ApiResponse deleteBusinessPlan( )); } - @Operation(summary = "서브섹션을 생성 또는 수정합니다.") @PostMapping("/{planId}/subsections") - public ApiResponse upsertSubSection( + public ApiResponse upsertSubSection( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId, @Valid @RequestBody SubSectionCreateRequest request @@ -127,9 +114,8 @@ public ApiResponse upsertSubSection( )); } - @Operation(summary = "서브섹션을 조회합니다.") @GetMapping("/{planId}/subsections/{subSectionType}") - public ApiResponse getSubSection( + public ApiResponse getSubSection( @AuthenticationPrincipal AuthDetails authDetails, @PathVariable Long planId, @PathVariable SubSectionType subSectionType @@ -139,7 +125,6 @@ public ApiResponse getSubSection( )); } - @Operation(summary = "서브섹션의 체크리스트를 점검 후 업데이트합니다.") @PostMapping("/{planId}/subsections/check-and-update") public ApiResponse> checkAndUpdateSubSection( @AuthenticationPrincipal AuthDetails authDetails, @@ -151,9 +136,8 @@ 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 71df82ec..635d4e35 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/businessplan/webapi/swagger/BusinessPlanApiDoc.java b/src/main/java/starlight/adapter/businessplan/webapi/swagger/BusinessPlanApiDoc.java new file mode 100644 index 00000000..0d6a74ea --- /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 + ); +} diff --git a/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java b/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java index 92501813..519bb666 100644 --- a/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java +++ b/src/main/java/starlight/adapter/expert/persistence/ExpertJpa.java @@ -18,6 +18,8 @@ @Component @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 { @@ -30,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/adapter/expert/webapi/ExpertController.java b/src/main/java/starlight/adapter/expert/webapi/ExpertController.java index ce98ccd8..c92942a8 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/ExpertApplicationJpaPort.java b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java similarity index 94% rename from src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpaPort.java rename to src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java index a9a9a860..71896ab7 100644 --- a/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpaPort.java +++ b/src/main/java/starlight/adapter/expertApplication/persistence/ExpertApplicationJpa.java @@ -16,7 +16,8 @@ @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 { diff --git a/src/main/java/starlight/adapter/expertReport/webapi/ExpertReportController.java b/src/main/java/starlight/adapter/expertReport/webapi/ExpertReportController.java index 1c400b76..2033fbf9 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/persistence/MemberJpa.java b/src/main/java/starlight/adapter/member/persistence/MemberJpa.java index 79e6b1a4..4ffc200b 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/adapter/member/webapi/MemberController.java b/src/main/java/starlight/adapter/member/webapi/MemberController.java index bfa19d73..5bbc0113 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/adapter/aireport/infrastructure/storage/NcpPresignedUrlProvider.java b/src/main/java/starlight/adapter/shared/infrastructure/storage/NcpPresignedUrlProvider.java similarity index 93% 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 9c04da39..06de3103 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; @@ -12,7 +12,9 @@ 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.domain.aireport.exception.AiReportErrorType; +import starlight.domain.aireport.exception.AiReportException; import starlight.shared.dto.infrastructure.PreSignedUrlResponse; import java.net.URLEncoder; @@ -22,7 +24,7 @@ @Slf4j @Service @RequiredArgsConstructor -public class NcpPresignedUrlProvider implements PresignedUrlProvider { +public class NcpPresignedUrlProvider implements PresignedUrlProviderPort { private final S3Client ncpS3Client; private final S3Presigner ncpS3Presigner; @@ -79,12 +81,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); + 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 new file mode 100644 index 00000000..5de7c3b1 --- /dev/null +++ b/src/main/java/starlight/application/aireport/AiReportService.java @@ -0,0 +1,179 @@ +package starlight.application.aireport; + +import com.fasterxml.jackson.core.JsonProcessingException; +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.application.aireport.provided.AiReportUseCase; +import starlight.application.aireport.provided.dto.AiReportResult; +import starlight.application.aireport.required.*; +import starlight.application.aireport.util.AiReportResponseParser; +import starlight.application.businessplan.util.BusinessPlanContentExtractor; +import starlight.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 AiReportService implements AiReportUseCase { + + private final BusinessPlanCommandLookupPort businessPlanCommandLookupPort; + private final BusinessPlanQueryLookupPort businessPlanQueryLookupPort; + 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; + + @Override + public AiReportResult gradeBusinessPlan(Long planId, Long memberId) { + log.info("사업계획서 AI 채점 시작. planId: {}, memberId: {}", planId, memberId); + + BusinessPlan plan = businessPlanQueryLookupPort.findByIdOrThrow(planId); + checkBusinessPlanOwned(plan, memberId); + checkBusinessPlanWritingCompleted(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(aiReport); + } + + @Override + 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); + + 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에 돌리는 방식을 사용 + 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(aiReport); + } + + @Override + @Transactional(readOnly = true) + public AiReportResult getAiReport(Long planId, Long memberId) { + BusinessPlan plan = businessPlanQueryLookupPort.findByIdOrThrow(planId); + checkBusinessPlanOwned(plan, memberId); + + AiReport aiReport = aiReportQueryPort.findByBusinessPlanId(planId) + .orElseThrow(() -> new AiReportException(AiReportErrorType.AI_REPORT_NOT_FOUND)); + + return responseParser.toResponse(aiReport); + } + + private String getRawJsonAiReportResponseFromGradingResult(AiReportResult gradingResult) { + JsonNode gradingJsonNode = responseParser.convertToJsonNode(gradingResult); + String rawJsonString; + try { + rawJsonString = objectMapper.writeValueAsString(gradingJsonNode); + } catch (JsonProcessingException e) { + throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED, e); + } + return rawJsonString; + } + + private AiReport upsertAiReportWithRawJsonStr(String rawJsonString, BusinessPlan plan) { + Optional existingReport = aiReportQueryPort.findByBusinessPlanId(plan.getId()); + + AiReport aiReport; + if (existingReport.isPresent()) { + aiReport = existingReport.get(); + aiReport.update(rawJsonString); + } else { + aiReport = AiReport.create(plan.getId(), rawJsonString); + } + + plan.updateStatus(PlanStatus.AI_REVIEWED); + businessPlanCommandLookupPort.save(plan); + + return aiReportCommandPort.save(aiReport); + } + + private void checkBusinessPlanOwned(BusinessPlan plan, Long memberId) { + if (!plan.isOwnedBy(memberId)) { + throw new AiReportException(AiReportErrorType.UNAUTHORIZED_ACCESS); + } + } + + private void checkBusinessPlanWritingCompleted(BusinessPlan plan) { + if (!plan.areWritingCompleted()) { + 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/aireport/AiReportServiceImpl.java b/src/main/java/starlight/application/aireport/AiReportServiceImpl.java deleted file mode 100644 index 6a7e123f..00000000 --- a/src/main/java/starlight/application/aireport/AiReportServiceImpl.java +++ /dev/null @@ -1,129 +0,0 @@ -package starlight.application.aireport; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -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.application.businessplan.util.BusinessPlanContentExtractor; -import starlight.application.aireport.required.OcrProvider; -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 java.util.Optional; - -@Service -@RequiredArgsConstructor -@Transactional -public class AiReportServiceImpl implements AiReportService { - - private final BusinessPlanQuery businessPlanQuery; - private final BusinessPlanService businessPlanService; - private final AiReportQuery aiReportQuery; - private final AiReportGrader aiReportGrader; - private final ObjectMapper objectMapper; - private final OcrProvider ocrProvider; - private final AiReportResponseParser responseParser; - private final BusinessPlanContentExtractor contentExtractor; - - @Override - public AiReportResponse gradeBusinessPlan(Long planId, Long memberId) { - - BusinessPlan plan = businessPlanQuery.findByIdOrThrow(planId); - checkBusinessPlanOwned(plan, memberId); - checkBusinessPlanWritingCompleted(plan); - - AiReportResponse gradingResult = aiReportGrader.gradeContent(contentExtractor.extractContent(plan)); - - String rawJsonString = getRawJsonAiReportResponseFromGradingResult(gradingResult); - - AiReport aiReport = upsertAiReportWithRawJsonStr(rawJsonString, plan); - - return responseParser.toResponse(aiReportQuery.save(aiReport)); - } - - @Override - public AiReportResponse createAndGradePdfBusinessPlan(String title, String pdfUrl, Long memberId) { - - BusinessPlanResponse.Result businessPlanResult = businessPlanService.createBusinessPlanWithPdf( - title, - pdfUrl, - memberId - ); - Long businessPlanId = businessPlanResult.businessPlanId(); - BusinessPlan plan = businessPlanQuery.findByIdOrThrow(businessPlanId); - - String pdfText = ocrProvider.ocrPdfTextByUrl(pdfUrl); - - AiReportResponse gradingResult = aiReportGrader.gradeContent(pdfText); - - String rawJsonString = getRawJsonAiReportResponseFromGradingResult(gradingResult); - - AiReport aiReport = upsertAiReportWithRawJsonStr(rawJsonString, plan); - - return responseParser.toResponse(aiReportQuery.save(aiReport)); - } - - @Override - @Transactional(readOnly = true) - public AiReportResponse getAiReport(Long planId, Long memberId) { - BusinessPlan plan = businessPlanQuery.findByIdOrThrow(planId); - checkBusinessPlanOwned(plan, memberId); - - AiReport aiReport = aiReportQuery.findByBusinessPlanId(planId) - .orElseThrow(() -> new AiReportException(AiReportErrorType.AI_REPORT_NOT_FOUND)); - - return responseParser.toResponse(aiReport); - } - - private String getRawJsonAiReportResponseFromGradingResult(AiReportResponse gradingResult) { - JsonNode gradingJsonNode = responseParser.convertToJsonNode(gradingResult); - String rawJsonString; - try { - rawJsonString = objectMapper.writeValueAsString(gradingJsonNode); - } catch (JsonProcessingException e) { - throw new RuntimeException("Failed to convert JsonNode to string", e); - } - return rawJsonString; - } - - private AiReport upsertAiReportWithRawJsonStr(String rawJsonString, BusinessPlan plan) { - Optional existingReport = aiReportQuery.findByBusinessPlanId(plan.getId()); - - AiReport aiReport; - if (existingReport.isPresent()) { - aiReport = existingReport.get(); - aiReport.update(rawJsonString); - } else { - aiReport = AiReport.create(plan.getId(), rawJsonString); - } - plan.updateStatus(PlanStatus.AI_REVIEWED); - businessPlanQuery.save(plan); - - return aiReport; - } - - private void checkBusinessPlanOwned(BusinessPlan plan, Long memberId) { - if (!plan.isOwnedBy(memberId)) { - throw new AiReportException(AiReportErrorType.UNAUTHORIZED_ACCESS); - } - } - - private void checkBusinessPlanWritingCompleted(BusinessPlan plan) { - if (!plan.areWritingCompleted()) { - throw new AiReportException(AiReportErrorType.NOT_READY_FOR_AI_REPORT); - } - } -} 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 6c618fd6..00000000 --- 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/AiReportUseCase.java b/src/main/java/starlight/application/aireport/provided/AiReportUseCase.java new file mode 100644 index 00000000..1f8f4a21 --- /dev/null +++ b/src/main/java/starlight/application/aireport/provided/AiReportUseCase.java @@ -0,0 +1,11 @@ +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); +} \ 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/AiReportResult.java similarity index 94% rename from src/main/java/starlight/application/aireport/provided/dto/AiReportResponse.java rename to src/main/java/starlight/application/aireport/provided/dto/AiReportResult.java index 93ec0c04..a5135d4e 100644 --- a/src/main/java/starlight/application/aireport/provided/dto/AiReportResponse.java +++ b/src/main/java/starlight/application/aireport/provided/dto/AiReportResult.java @@ -7,7 +7,7 @@ * AI 리포트 응답 DTO * LLM 채점 결과와 API 응답을 모두 담는 통합 DTO */ -public record AiReportResponse( +public record AiReportResult( Long id, // null 가능 (LLM 결과 파싱 시에는 null) Long businessPlanId, // null 가능 (LLM 결과 파싱 시에는 null) Integer totalScore, @@ -32,7 +32,7 @@ public record StrengthWeakness( /** * LLM 결과만으로 AiReportResponse 생성 (id, businessPlanId는 null) */ - public static AiReportResponse fromGradingResult( + public static AiReportResult fromGradingResult( Integer problemRecognitionScore, Integer feasibilityScore, Integer growthStrategyScore, @@ -43,7 +43,7 @@ public static AiReportResponse fromGradingResult( ) { Integer totalScore = sumTotalScore(problemRecognitionScore, feasibilityScore, growthStrategyScore, teamCompetenceScore); - return new AiReportResponse( + return new AiReportResult( null, null, totalScore, 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 00000000..5acf707d --- /dev/null +++ b/src/main/java/starlight/application/aireport/required/AiReportCommandPort.java @@ -0,0 +1,8 @@ +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/AiReportGrader.java b/src/main/java/starlight/application/aireport/required/AiReportGrader.java deleted file mode 100644 index 0ba2d255..00000000 --- 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/AiReportQueryPort.java similarity index 73% rename from src/main/java/starlight/application/aireport/required/AiReportQuery.java rename to src/main/java/starlight/application/aireport/required/AiReportQueryPort.java index 8e18704a..29520365 100644 --- a/src/main/java/starlight/application/aireport/required/AiReportQuery.java +++ b/src/main/java/starlight/application/aireport/required/AiReportQueryPort.java @@ -4,8 +4,8 @@ import java.util.Optional; -public interface AiReportQuery { - AiReport save(AiReport aiReport); +public interface AiReportQueryPort { + Optional findByBusinessPlanId(Long businessPlanId); } 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 00000000..66a9dfb4 --- /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/BusinessPlanQueryLookupPort.java b/src/main/java/starlight/application/aireport/required/BusinessPlanQueryLookupPort.java new file mode 100644 index 00000000..f0ea03ae --- /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); +} diff --git a/src/main/java/starlight/application/aireport/required/OcrProvider.java b/src/main/java/starlight/application/aireport/required/OcrProviderPort.java similarity index 85% rename from src/main/java/starlight/application/aireport/required/OcrProvider.java rename to src/main/java/starlight/application/aireport/required/OcrProviderPort.java index 0e8050d5..6fe3adbc 100644 --- a/src/main/java/starlight/application/aireport/required/OcrProvider.java +++ b/src/main/java/starlight/application/aireport/required/OcrProviderPort.java @@ -2,7 +2,7 @@ import starlight.shared.dto.infrastructure.OcrResponse; -public interface OcrProvider { +public interface OcrProviderPort { OcrResponse ocrPdfByUrl(String pdfUrl) ; diff --git a/src/main/java/starlight/application/aireport/required/PresignedUrlProvider.java b/src/main/java/starlight/application/aireport/required/PresignedUrlProviderPort.java similarity index 84% rename from src/main/java/starlight/application/aireport/required/PresignedUrlProvider.java rename to src/main/java/starlight/application/aireport/required/PresignedUrlProviderPort.java index 0a262be0..4417c3e9 100644 --- a/src/main/java/starlight/application/aireport/required/PresignedUrlProvider.java +++ b/src/main/java/starlight/application/aireport/required/PresignedUrlProviderPort.java @@ -2,7 +2,7 @@ import starlight.shared.dto.infrastructure.PreSignedUrlResponse; -public interface PresignedUrlProvider { +public interface PresignedUrlProviderPort { PreSignedUrlResponse getPreSignedUrl(Long userId, String originalFileName); 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 00000000..f7ffcdfa --- /dev/null +++ b/src/main/java/starlight/application/aireport/required/ReportGraderPort.java @@ -0,0 +1,14 @@ +package starlight.application.aireport.required; + +import starlight.application.aireport.provided.dto.AiReportResult; +import starlight.shared.enumerate.SectionType; + +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/aireport/util/AiReportResponseParser.java b/src/main/java/starlight/application/aireport/util/AiReportResponseParser.java new file mode 100644 index 00000000..c03d8d5d --- /dev/null +++ b/src/main/java/starlight/application/aireport/util/AiReportResponseParser.java @@ -0,0 +1,477 @@ +package starlight.application.aireport.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; + +@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로 파싱 (전체 리포트용) + * 4개의 전체 점수 필드를 모두 요구 + */ + public AiReportResult parse(String 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); + + // 3. JSON 파싱 시도 + JsonNode jsonNode = objectMapper.readTree(cleanedJson); + + // 4. 필수 필드 존재 여부 확인 (전체 리포트는 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) { + throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } + } + + /** + * 섹션별 채점 응답을 파싱 (섹션별 Agent용) + * 하나의 섹션 점수만 포함하는 응답을 처리합니다. + * 예: {"feasibilityScore": 0, "sectionScores": [...]} + */ + public AiReportResult parseSectionResponse(String 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); + + // 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) { + throw new AiReportException(AiReportErrorType.AI_RESPONSE_PARSING_FAILED); + } + + // 5. 섹션별 응답 파싱 (없는 필드는 null로 설정) + List sectionScores = parseSectionScores( + jsonNode.path("sectionScores")); + + // strengths와 weaknesses는 섹션별 응답에는 없음 + return AiReportResult.fromGradingResult( + problemRecognitionScore, + feasibilityScore, + growthStrategyScore, + teamCompetenceScore, + sectionScores, + List.of(), // strengths는 빈 리스트 + List.of() // weaknesses는 빈 리스트 + ); + + } catch (Exception 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; + } + } catch (Exception e2) { + } + } + } + + // 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로 변환 + */ + private AiReportResult parseFromJsonNode(JsonNode jsonNode) { + Integer problemRecognitionScore = null; + Integer feasibilityScore = null; + Integer growthStrategyScore = null; + Integer teamCompetenceScore = null; + + if (jsonNode.has("problemRecognitionScore") && !jsonNode.path("problemRecognitionScore").isNull()) { + problemRecognitionScore = jsonNode.path("problemRecognitionScore").asInt(); + } + if (jsonNode.has("feasibilityScore") && !jsonNode.path("feasibilityScore").isNull()) { + feasibilityScore = jsonNode.path("feasibilityScore").asInt(); + } + if (jsonNode.has("growthStrategyScore") && !jsonNode.path("growthStrategyScore").isNull()) { + growthStrategyScore = jsonNode.path("growthStrategyScore").asInt(); + } + if (jsonNode.has("teamCompetenceScore") && !jsonNode.path("teamCompetenceScore").isNull()) { + teamCompetenceScore = jsonNode.path("teamCompetenceScore").asInt(); + } + + // 강점 파싱 + List strengths = parseStrengthWeaknessList(jsonNode.path("strengths")); + + // 약점 파싱 + List weaknesses = parseStrengthWeaknessList(jsonNode.path("weaknesses")); + + // sectionScores 파싱: sectionType과 gradingListScores만 포함 + List sectionScores = parseSectionScores( + jsonNode.path("sectionScores")); + + return AiReportResult.fromGradingResult( + problemRecognitionScore, + feasibilityScore, + growthStrategyScore, + teamCompetenceScore, + sectionScores, + strengths, + weaknesses); + } + + /** + * 강점/약점 리스트 파싱 (슈퍼바이저용) + */ + public List parseStrengthWeakness(String llmResponse, String type) { + try { + String cleanedJson = cleanJsonResponse(llmResponse); + JsonNode jsonNode = objectMapper.readTree(cleanedJson); + + JsonNode targetNode = jsonNode.path(type); + return parseStrengthWeaknessList(targetNode); + } catch (Exception e) { + log.error("Failed to parse strength/weakness from supervisor response. Type: {}", type, e); + return List.of(); + } + } + + /** + * 강점/약점 리스트 파싱 + */ + private List parseStrengthWeaknessList(JsonNode node) { + List list = new ArrayList<>(); + if (node.isArray()) { + for (JsonNode itemNode : node) { + list.add(new AiReportResult.StrengthWeakness( + itemNode.path("title").asText(""), + itemNode.path("content").asText(""))); + } + } + return list; + } + + /** + * 섹션 점수 리스트 파싱 + * 불완전한 항목은 건너뛰거나 기본값으로 대체 + */ + private List parseSectionScores(JsonNode node) { + List list = new ArrayList<>(); + if (node.isArray()) { + for (JsonNode sectionScoreNode : node) { + try { + String sectionType = sectionScoreNode.path("sectionType").asText(""); + 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) { + 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/application/aireport/util/SectionScoreExtractor.java b/src/main/java/starlight/application/aireport/util/SectionScoreExtractor.java new file mode 100644 index 00000000..e92917af --- /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/application/backoffice/expert/BackofficeExpertCommandService.java b/src/main/java/starlight/application/backoffice/expert/BackofficeExpertCommandService.java new file mode 100644 index 00000000..0c680d46 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/BackofficeExpertCommandService.java @@ -0,0 +1,101 @@ +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()); + + if (input.careers() != null) { + 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 new file mode 100644 index 00000000..57ac2ae8 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/BackofficeExpertQueryService.java @@ -0,0 +1,52 @@ +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(); + } + + @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 00000000..0d8ef20d --- /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 new file mode 100644 index 00000000..1abf2b48 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/provided/BackofficeExpertQueryUseCase.java @@ -0,0 +1,12 @@ +package starlight.application.backoffice.expert.provided; + +import starlight.application.backoffice.expert.provided.dto.result.BackofficeExpertDetailResult; + +import java.util.List; + +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 00000000..95f5c52a --- /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 00000000..8ad95704 --- /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 00000000..cca4a890 --- /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 00000000..24a92a29 --- /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 00000000..2d7fe445 --- /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 00000000..7514b81c --- /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/provided/dto/result/BackofficeExpertDetailResult.java b/src/main/java/starlight/application/backoffice/expert/provided/dto/result/BackofficeExpertDetailResult.java new file mode 100644 index 00000000..ae1cb972 --- /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 00000000..3bfd7304 --- /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/BackofficeExpertCommandPort.java b/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertCommandPort.java new file mode 100644 index 00000000..3b329949 --- /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 new file mode 100644 index 00000000..c6da3181 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/expert/required/BackofficeExpertQueryPort.java @@ -0,0 +1,14 @@ +package starlight.application.backoffice.expert.required; + +import starlight.domain.expert.entity.Expert; + +import java.util.List; + +public interface BackofficeExpertQueryPort { + + Expert findByIdOrThrow(Long id); + + Expert findByIdWithCareersTagsCategories(Long id); + + List findAllWithCareersTagsCategories(); +} 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 00000000..40620c70 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendLogEventHandler.java @@ -0,0 +1,41 @@ +package starlight.application.backoffice.mail; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.mail.BackofficeMailSendLog; + +@Slf4j +@Component +@RequiredArgsConstructor +public class BackofficeMailSendLogEventHandler { + + private final BackofficeMailSendLogCommandPort logCommandPort; + + @Async("emailTaskExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION) + public void handle(BackofficeMailSendEvent event) { + String recipients = EmailMaskingUtils.maskRecipients(event.to()); + + BackofficeMailSendLog mailSendLog = BackofficeMailSendLog.create( + recipients, + event.subject(), + event.contentType(), + event.success(), + event.errorMessage() + ); + + try { + logCommandPort.save(mailSendLog); + } catch (DataAccessException exception) { + log.warn("[MAIL] send log save failed. subject={}", event.subject(), exception); + } + } +} 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 00000000..536b72f7 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailSendService.java @@ -0,0 +1,82 @@ +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 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 ApplicationEventPublisher eventPublisher; + + @Override + @Transactional + public void send(BackofficeMailSendInput input) { + BackofficeMailContentType contentType = BackofficeMailContentTypeParser.parse(input.contentType()); + + try { + validate(input, contentType); + + mailSenderPort.send(input, contentType); + + BackofficeMailSendEvent log = BackofficeMailSendEvent.of( + input.to(), + input.subject(), + contentType, + true, + null + ); + eventPublisher.publishEvent(log); + } catch (BackofficeException exception) { + publishFailureEvent(input, contentType, exception.getMessage()); + throw exception; + } catch (Exception exception) { + publishFailureEvent(input, contentType, exception.getMessage()); + throw new BackofficeException(BackofficeErrorType.MAIL_SEND_FAILED); + } + } + + private void validate(BackofficeMailSendInput input, BackofficeMailContentType contentType) { + if (input.to() == null || input.to().isEmpty()) { + throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_REQUEST); + } + if (input.subject() == null || input.subject().isBlank()) { + throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_REQUEST); + } + if (contentType == BackofficeMailContentType.HTML) { + if (input.html() == null || input.html().isBlank()) { + throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_REQUEST); + } + } + if (contentType == BackofficeMailContentType.TEXT) { + if (input.text() == null || input.text().isBlank()) { + throw new BackofficeException(BackofficeErrorType.INVALID_MAIL_REQUEST); + } + } + } + + private void publishFailureEvent( + BackofficeMailSendInput input, + BackofficeMailContentType contentType, + String errorMessage + ) { + eventPublisher.publishEvent(BackofficeMailSendEvent.of( + input.to(), + input.subject(), + contentType, + false, + errorMessage + )); + } +} 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 00000000..2d588071 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/BackofficeMailTemplateService.java @@ -0,0 +1,81 @@ +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.application.backoffice.mail.util.BackofficeMailContentTypeParser; +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; + +@Service +@RequiredArgsConstructor +public class BackofficeMailTemplateService implements BackofficeMailTemplateUseCase { + + private final BackofficeMailTemplateCommandPort templateCommandPort; + private final BackofficeMailTemplateQueryPort templateQueryPort; + + @Override + @Transactional + public BackofficeMailTemplateResult createTemplate(BackofficeMailTemplateCreateInput input) { + BackofficeMailContentType contentType = BackofficeMailContentTypeParser.parse(input.contentType()); + BackofficeMailTemplate template = BackofficeMailTemplate.create( + input.name(), + input.title(), + 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 BackofficeMailTemplateResult.of( + 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/event/BackofficeMailSendEvent.java b/src/main/java/starlight/application/backoffice/mail/event/BackofficeMailSendEvent.java new file mode 100644 index 00000000..b13163b1 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/event/BackofficeMailSendEvent.java @@ -0,0 +1,23 @@ +package starlight.application.backoffice.mail.event; + +import starlight.domain.backoffice.mail.BackofficeMailContentType; + +import java.util.List; + +public record BackofficeMailSendEvent( + List to, + String subject, + BackofficeMailContentType contentType, + 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/BackofficeMailSendUseCase.java b/src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailSendUseCase.java new file mode 100644 index 00000000..37824882 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/provided/BackofficeMailSendUseCase.java @@ -0,0 +1,8 @@ +package starlight.application.backoffice.mail.provided; + +import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendInput; + +public interface BackofficeMailSendUseCase { + + void 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 00000000..b79c0896 --- /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 00000000..dde0c20c --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailSendInput.java @@ -0,0 +1,21 @@ +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 +) { + 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 new file mode 100644 index 00000000..83867cf0 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/provided/dto/input/BackofficeMailTemplateCreateInput.java @@ -0,0 +1,19 @@ +package starlight.application.backoffice.mail.provided.dto.input; + +public record BackofficeMailTemplateCreateInput( + String name, + String title, + 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 new file mode 100644 index 00000000..bbe225b6 --- /dev/null +++ b/src/main/java/starlight/application/backoffice/mail/provided/dto/result/BackofficeMailTemplateResult.java @@ -0,0 +1,33 @@ +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 +) { + 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 + ); + } +} 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 00000000..7d0815e0 --- /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 00000000..ff1bd221 --- /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 00000000..797933c4 --- /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 00000000..8da3a10a --- /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/application/backoffice/mail/util/BackofficeMailContentTypeParser.java b/src/main/java/starlight/application/backoffice/mail/util/BackofficeMailContentTypeParser.java new file mode 100644 index 00000000..6bf8717d --- /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 00000000..9c35246c --- /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/application/businessplan/BusinessPlanServiceImpl.java b/src/main/java/starlight/application/businessplan/BusinessPlanService.java similarity index 71% rename from src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java rename to src/main/java/starlight/application/businessplan/BusinessPlanService.java index 157b89b7..8675083f 100644 --- a/src/main/java/starlight/application/businessplan/BusinessPlanServiceImpl.java +++ b/src/main/java/starlight/application/businessplan/BusinessPlanService.java @@ -9,14 +9,15 @@ 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.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.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; @@ -32,69 +33,70 @@ @Service @RequiredArgsConstructor @Transactional -public class BusinessPlanServiceImpl implements BusinessPlanService { +public class BusinessPlanService implements BusinessPlanUseCase { - private final BusinessPlanQuery businessPlanQuery; - private final MemberQueryPort memberQuery; - private final ChecklistGrader checklistGrader; + private final BusinessPlanCommandPort businessPlanCommandPort; + private final BusinessPlanQueryPort businessPlanQueryPort; + private final MemberLookupPort memberLookupPort; + private final ChecklistGraderPort checklistGrader; private final ObjectMapper objectMapper; @Override - public BusinessPlanResponse.Result createBusinessPlan(Long memberId) { - Member member = memberQuery.findByIdOrThrow(memberId); + public BusinessPlanResult.Result createBusinessPlan(Long memberId) { + Member member = memberLookupPort.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"); + return BusinessPlanResult.Result.from(businessPlanCommandPort.save(plan), "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 ); - return BusinessPlanResponse.Result.from(businessPlanQuery.save(plan), "PDF Business plan created"); + return BusinessPlanResult.Result.from(businessPlanCommandPort.save(plan), "PDF Business plan created"); } @Override @Transactional(readOnly = true) - public BusinessPlanResponse.Result getBusinessPlanInfo(Long planId, Long memberId) { + public BusinessPlanResult.Result getBusinessPlanInfo(Long planId, Long memberId) { BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); - return BusinessPlanResponse.Result.from(plan, "Business plan retrieved"); + return BusinessPlanResult.Result.from(plan, "Business plan retrieved"); } @Override @Transactional(readOnly = true) - public BusinessPlanResponse.Detail getBusinessPlanDetail(Long planId, Long memberId) { - BusinessPlan plan = businessPlanQuery.getOrThrowWithAllSubSections(planId); + public BusinessPlanResult.Detail getBusinessPlanDetail(Long planId, Long memberId) { + BusinessPlan plan = businessPlanQueryPort.findByIdWithAllSubSectionsOrThrow(planId); if (!plan.isOwnedBy(memberId)) { throw new BusinessPlanException(BusinessPlanErrorType.UNAUTHORIZED_ACCESS); } - List subSectionDetailList = Arrays.stream(SubSectionType.values()) + List subSectionDetailList = Arrays.stream(SubSectionType.values()) .map(type -> getSectionByPlanAndType(plan, type.getSectionType()).getSubSectionByType(type)) .filter(Objects::nonNull) - .map(SubSectionResponse.Detail::from) + .map(SubSectionResult.Detail::from) .toList(); - return BusinessPlanResponse.Detail.from(plan, subSectionDetailList); + return BusinessPlanResult.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) + public BusinessPlanResult.PreviewPage getBusinessPlanList(Long memberId, Pageable pageable) { + Page page = businessPlanQueryPort.findPreviewPage(memberId, pageable); + List content = page.getContent().stream() + .map(BusinessPlanResult.Preview::from) .toList(); - return BusinessPlanResponse.PreviewPage.from(content, page); + return BusinessPlanResult.PreviewPage.from(content, page); } @Override @@ -103,23 +105,23 @@ public String updateBusinessPlanTitle(Long planId, String title, Long memberId) plan.updateTitle(title); - businessPlanQuery.save(plan); + businessPlanCommandPort.save(plan); return plan.getTitle(); } @Override - public BusinessPlanResponse.Result deleteBusinessPlan(Long planId, Long memberId) { + public BusinessPlanResult.Result deleteBusinessPlan(Long planId, Long memberId) { BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); - BusinessPlanResponse.Result result = BusinessPlanResponse.Result.from(plan, "Business plan deleted"); - businessPlanQuery.delete(plan); + BusinessPlanResult.Result result = BusinessPlanResult.Result.from(plan, "Business plan deleted"); + businessPlanCommandPort.delete(plan); return result; } @Override - public SubSectionResponse.Result upsertSubSection( + public SubSectionResult.Result upsertSubSection( Long planId, JsonNode jsonNode, List checks, @@ -151,16 +153,16 @@ public SubSectionResponse.Result upsertSubSection( message = "Subsection writing completed"; } - BusinessPlan savedPlan = businessPlanQuery.save(plan); + BusinessPlan savedPlan = businessPlanCommandPort.save(plan); SubSection persistedSubSection = getSectionByPlanAndType(savedPlan, sectionType) .getSubSectionByType(subSectionType); - return SubSectionResponse.Result.from(persistedSubSection, message); + return SubSectionResult.Result.from(persistedSubSection, message); } @Override @Transactional(readOnly = true) - public SubSectionResponse.Detail getSubSectionDetail(Long planId, SubSectionType subSectionType, Long memberId) { + public SubSectionResult.Detail getSubSectionDetail(Long planId, SubSectionType subSectionType, Long memberId) { BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); SectionType sectionType = subSectionType.getSectionType(); @@ -169,7 +171,7 @@ public SubSectionResponse.Detail getSubSectionDetail(Long planId, SubSectionType throw new BusinessPlanException(BusinessPlanErrorType.SUBSECTION_NOT_FOUND); } - return SubSectionResponse.Detail.from(subSection); + return SubSectionResult.Detail.from(subSection); } @Override @@ -195,13 +197,13 @@ public List checkAndUpdateSubSection( subSection.update(content, rawJsonStr, checks); - businessPlanQuery.save(plan); + businessPlanCommandPort.save(plan); return checks; } @Override - public SubSectionResponse.Result deleteSubSection(Long planId, SubSectionType subSectionType, Long memberId) { + public SubSectionResult.Result deleteSubSection(Long planId, SubSectionType subSectionType, Long memberId) { BusinessPlan plan = getOwnedBusinessPlanOrThrow(planId, memberId); SectionType sectionType = subSectionType.getSectionType(); @@ -210,10 +212,10 @@ public SubSectionResponse.Result deleteSubSection(Long planId, SubSectionType su if (target == null) { throw new BusinessPlanException(BusinessPlanErrorType.SUBSECTION_NOT_FOUND); } - SubSectionResponse.Result result = SubSectionResponse.Result.from(target, "Subsection deleted"); + 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 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/BusinessPlanUseCase.java b/src/main/java/starlight/application/businessplan/provided/BusinessPlanUseCase.java new file mode 100644 index 00000000..2c9d431b --- /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/BusinessPlanResponse.java b/src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResult.java similarity index 88% rename from src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResponse.java rename to src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResult.java index 6f837466..b4c6a971 100644 --- a/src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResponse.java +++ b/src/main/java/starlight/application/businessplan/provided/dto/BusinessPlanResult.java @@ -8,7 +8,7 @@ import java.time.LocalDateTime; import java.util.List; -public record BusinessPlanResponse() { +public record BusinessPlanResult() { public record Result( Long businessPlanId, @@ -30,11 +30,11 @@ public record Detail( Long businessPlanId, String title, PlanStatus planStatus, - List subSectionDetailList + List subSectionDetailList ) { public static Detail from( BusinessPlan businessPlan, - List subSectionDetailList + List subSectionDetailList ) { return new Detail( businessPlan.getId(), @@ -77,8 +77,8 @@ public record PreviewPage( boolean first, boolean last ) { - public static PreviewPage from(List content, Page page) { - return new BusinessPlanResponse.PreviewPage( + public static PreviewPage from(List content, Page page) { + return new BusinessPlanResult.PreviewPage( content, page.getNumber() + 1, page.getSize(), diff --git a/src/main/java/starlight/application/businessplan/provided/dto/SubSectionResponse.java b/src/main/java/starlight/application/businessplan/provided/dto/SubSectionResult.java similarity index 96% rename from src/main/java/starlight/application/businessplan/provided/dto/SubSectionResponse.java rename to src/main/java/starlight/application/businessplan/provided/dto/SubSectionResult.java index 019721d6..8607e7cf 100644 --- a/src/main/java/starlight/application/businessplan/provided/dto/SubSectionResponse.java +++ b/src/main/java/starlight/application/businessplan/provided/dto/SubSectionResult.java @@ -4,7 +4,7 @@ import starlight.domain.businessplan.entity.SubSection; import starlight.domain.businessplan.enumerate.SubSectionType; -public record SubSectionResponse() { +public record SubSectionResult() { public record Result( SubSectionType subSectionType, 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 00000000..4adbed40 --- /dev/null +++ b/src/main/java/starlight/application/businessplan/required/BusinessPlanCommandPort.java @@ -0,0 +1,10 @@ +package starlight.application.businessplan.required; + +import starlight.domain.businessplan.entity.BusinessPlan; + +public interface BusinessPlanCommandPort { + + BusinessPlan save(BusinessPlan businessPlan); + + void delete(BusinessPlan businessPlan); +} diff --git a/src/main/java/starlight/application/businessplan/required/BusinessPlanQuery.java b/src/main/java/starlight/application/businessplan/required/BusinessPlanQueryPort.java similarity index 63% rename from src/main/java/starlight/application/businessplan/required/BusinessPlanQuery.java rename to src/main/java/starlight/application/businessplan/required/BusinessPlanQueryPort.java index 355ec8f7..e4bfd5e8 100644 --- a/src/main/java/starlight/application/businessplan/required/BusinessPlanQuery.java +++ b/src/main/java/starlight/application/businessplan/required/BusinessPlanQueryPort.java @@ -4,15 +4,11 @@ import org.springframework.data.domain.Page; import starlight.domain.businessplan.entity.BusinessPlan; -public interface BusinessPlanQuery { +public interface BusinessPlanQueryPort { BusinessPlan findByIdOrThrow(Long id); - BusinessPlan getOrThrowWithAllSubSections(Long id); - - BusinessPlan save(BusinessPlan businessPlan); - - void delete(BusinessPlan businessPlan); + BusinessPlan findByIdWithAllSubSectionsOrThrow(Long id); 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 33049e2a..00000000 --- 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/ChecklistGraderPort.java b/src/main/java/starlight/application/businessplan/required/ChecklistGraderPort.java new file mode 100644 index 00000000..0646b976 --- /dev/null +++ b/src/main/java/starlight/application/businessplan/required/ChecklistGraderPort.java @@ -0,0 +1,10 @@ +package starlight.application.businessplan.required; + +import starlight.domain.businessplan.enumerate.SubSectionType; + +import java.util.List; + +public interface ChecklistGraderPort { + + List check(SubSectionType subSectionType, String content); +} 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 00000000..978d72a5 --- /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/businessplan/required/SpellChecker.java b/src/main/java/starlight/application/businessplan/required/SpellCheckerPort.java similarity index 87% rename from src/main/java/starlight/application/businessplan/required/SpellChecker.java rename to src/main/java/starlight/application/businessplan/required/SpellCheckerPort.java index 347abd57..3c3bb666 100644 --- a/src/main/java/starlight/application/businessplan/required/SpellChecker.java +++ b/src/main/java/starlight/application/businessplan/required/SpellCheckerPort.java @@ -4,7 +4,7 @@ import java.util.List; -public interface SpellChecker { +public interface SpellCheckerPort { List check(String sentence); diff --git a/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java b/src/main/java/starlight/application/businessplan/util/BusinessPlanContentExtractor.java index 5f3e2eed..5555c488 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,59 @@ 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 케이스용) + * + * 현재 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; +// } } diff --git a/src/main/java/starlight/application/expert/ExpertAiReportQueryService.java b/src/main/java/starlight/application/expert/ExpertAiReportQueryService.java index 8c472ba2..a5c6691f 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/ExpertDetailQueryService.java b/src/main/java/starlight/application/expert/ExpertDetailQueryService.java index 88edceaa..3d20a8be 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_ACTIVE); + } 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 705b16ee..53c13d4c 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/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 63a11a09..df2e0fd8 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/main/java/starlight/application/expertApplication/ExpertApplicationCommandService.java b/src/main/java/starlight/application/expertApplication/ExpertApplicationCommandService.java index 82e00040..da6e409d 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 37d77dee..4d1a546d 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/ExpertReportServiceUseCase.java b/src/main/java/starlight/application/expertReport/provided/ExpertReportUseCase.java similarity index 94% rename from src/main/java/starlight/application/expertReport/provided/ExpertReportServiceUseCase.java rename to src/main/java/starlight/application/expertReport/provided/ExpertReportUseCase.java index 6e6e9f29..1eb15e34 100644 --- a/src/main/java/starlight/application/expertReport/provided/ExpertReportServiceUseCase.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/infrastructure/provided/LlmGenerator.java b/src/main/java/starlight/application/infrastructure/provided/LlmGenerator.java deleted file mode 100644 index 58b78684..00000000 --- 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); -} diff --git a/src/main/java/starlight/application/member/CredentialServiceImpl.java b/src/main/java/starlight/application/member/CredentialService.java similarity index 90% rename from src/main/java/starlight/application/member/CredentialServiceImpl.java rename to src/main/java/starlight/application/member/CredentialService.java index 04977d25..789073da 100644 --- a/src/main/java/starlight/application/member/CredentialServiceImpl.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/MemberQueryService.java b/src/main/java/starlight/application/member/MemberService.java similarity index 92% rename from src/main/java/starlight/application/member/MemberQueryService.java rename to src/main/java/starlight/application/member/MemberService.java index d635f8f0..d4cb9149 100644 --- a/src/main/java/starlight/application/member/MemberQueryService.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 e5fd1d0e..346add17 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/CredentialService.java b/src/main/java/starlight/application/member/provided/CredentialUseCase.java similarity index 90% rename from src/main/java/starlight/application/member/provided/CredentialService.java rename to src/main/java/starlight/application/member/provided/CredentialUseCase.java index d67b5e3d..f2d5051a 100644 --- a/src/main/java/starlight/application/member/provided/CredentialService.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/MemberQueryUseCase.java b/src/main/java/starlight/application/member/provided/MemberUseCase.java similarity index 89% rename from src/main/java/starlight/application/member/provided/MemberQueryUseCase.java rename to src/main/java/starlight/application/member/provided/MemberUseCase.java index 6977ddf8..4f0bbb9a 100644 --- a/src/main/java/starlight/application/member/provided/MemberQueryUseCase.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); diff --git a/src/main/java/starlight/application/member/required/MemberQueryPort.java b/src/main/java/starlight/application/member/required/MemberQueryPort.java index 71fe15a8..7aac87bc 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); } diff --git a/src/main/java/starlight/bootstrap/AiReportSectionAdvisorConfig.java b/src/main/java/starlight/bootstrap/AiReportSectionAdvisorConfig.java new file mode 100644 index 00000000..eacd22b8 --- /dev/null +++ b/src/main/java/starlight/bootstrap/AiReportSectionAdvisorConfig.java @@ -0,0 +1,45 @@ +package starlight.bootstrap; + +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.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.application.aireport.util.AiReportResponseParser; +import starlight.shared.enumerate.SectionType; + +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +@Configuration +@RequiredArgsConstructor +public class AiReportSectionAdvisorConfig { + + 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/bootstrap/AsyncConfig.java b/src/main/java/starlight/bootstrap/AsyncConfig.java index fee3c177..13ed1b22 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/bootstrap/SecurityConfig.java b/src/main/java/starlight/bootstrap/SecurityConfig.java index 50b0eaa2..4eeecc1a 100644 --- a/src/main/java/starlight/bootstrap/SecurityConfig.java +++ b/src/main/java/starlight/bootstrap/SecurityConfig.java @@ -8,6 +8,8 @@ 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.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; @@ -15,6 +17,14 @@ 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.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler; @@ -37,9 +47,15 @@ @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; private final ExceptionFilter exceptionFilter; private final JwtAccessDeniedHandler jwtAccessDeniedHandler; @@ -48,6 +64,39 @@ public class SecurityConfig { private final OAuth2SuccessHandler oAuth2SuccessHandler; @Bean + @Order(1) + public SecurityFilterChain backofficeFilterChain(HttpSecurity http) throws Exception { + CsrfTokenRequestAttributeHandler csrfTokenRequestHandler = new CsrfTokenRequestAttributeHandler(); + CookieCsrfTokenRepository csrfTokenRepository = CookieCsrfTokenRepository.withHttpOnlyFalse(); + boolean isDevProfile = List.of(environment.getActiveProfiles()).contains("dev"); + if (!isDevProfile) { + csrfTokenRepository.setCookieCustomizer(cookie -> cookie + .domain(backofficeCsrfCookieDomain) + .sameSite("None") + .secure(true) + ); + } + + http.securityMatcher("/v1/backoffice/**", "/login", "/logout") + .cors(Customizer.withDefaults()) + .csrf((csrf) -> csrf + .csrfTokenRepository(csrfTokenRepository) + .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); @@ -100,7 +149,9 @@ public CorsConfigurationSource corsConfigurationSource() { configuration.setAllowedOrigins(List.of( clientBaseUrl, - ServerBaseUrl + serverBaseUrl, + devBaseUrl, + officeBaseUrl )); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); @@ -120,8 +171,33 @@ public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + @Bean + 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(backofficePasswordHash) + .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(); } + } diff --git a/src/main/java/starlight/bootstrap/SwaggerConfig.java b/src/main/java/starlight/bootstrap/SwaggerConfig.java index df6ebb17..879c6f2e 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/main/java/starlight/domain/aireport/entity/AiReport.java b/src/main/java/starlight/domain/aireport/entity/AiReport.java index 234053c8..3af44f5e 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) diff --git a/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java b/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java index 95b85996..912baf9c 100644 --- a/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java +++ b/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java @@ -12,7 +12,10 @@ 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 채점에 실패했습니다."), + OBJECT_ACL_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "객체 공개 처리에 실패했습니다."), + AI_AGENT_DUPLICATED(HttpStatus.INTERNAL_SERVER_ERROR, "AI 리포트 에이전트가 중복입니다."); ; private final HttpStatus status; diff --git a/src/main/java/starlight/domain/aireport/exception/AiReportException.java b/src/main/java/starlight/domain/aireport/exception/AiReportException.java index 2b86898f..d5933913 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); + } } 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 00000000..a1d9f1f6 --- /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 00000000..008b2d87 --- /dev/null +++ b/src/main/java/starlight/domain/backoffice/exception/BackofficeException.java @@ -0,0 +1,15 @@ +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); + } + + public BackofficeException(ErrorType errorType, Throwable cause) { + super(errorType, cause); + } +} 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 00000000..308c8bf7 --- /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 00000000..dc29114e --- /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 00000000..feee431f --- /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; + } +} 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 00000000..a4e0fa5f --- /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 f1ce0af0..10fc42aa 100644 --- a/src/main/java/starlight/domain/expert/entity/Expert.java +++ b/src/main/java/starlight/domain/expert/entity/Expert.java @@ -5,13 +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 @@ -36,6 +46,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; @@ -54,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_CAREER_INVALID); + } + + 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 cd151cae..964699ea 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/enumerate/ExpertActiveStatus.java b/src/main/java/starlight/domain/expert/enumerate/ExpertActiveStatus.java new file mode 100644 index 00000000..198209ff --- /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; +} diff --git a/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java b/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java index e8179cd8..4453c73a 100644 --- a/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java +++ b/src/main/java/starlight/domain/expert/exception/ExpertErrorType.java @@ -9,8 +9,10 @@ @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_NOT_ACTIVE(HttpStatus.FORBIDDEN, "비활성 전문가입니다."), + EXPERT_CAREER_INVALID(HttpStatus.BAD_REQUEST, "경력 정보가 올바르지 않습니다."); ; private final HttpStatus status; diff --git a/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java b/src/main/java/starlight/shared/apiPayload/exception/GlobalException.java index 1c7a8198..7ecbd40f 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; + } +} diff --git a/src/main/java/starlight/shared/enumerate/SectionType.java b/src/main/java/starlight/shared/enumerate/SectionType.java index 6a4828dc..97c94542 100644 --- a/src/main/java/starlight/shared/enumerate/SectionType.java +++ b/src/main/java/starlight/shared/enumerate/SectionType.java @@ -7,11 +7,12 @@ @RequiredArgsConstructor public enum SectionType { - OVERVIEW("개요"), - PROBLEM_RECOGNITION("문제 인식"), - FEASIBILITY("실현 가능성"), - GROWTH_STRATEGY("성장 전략"), - TEAM_COMPETENCE("팀 역량"); + OVERVIEW("개요", null), + PROBLEM_RECOGNITION("문제 인식", "problem_recognition"), + FEASIBILITY("실현 가능성", "feasibility"), + GROWTH_STRATEGY("성장 전략", "growth_strategy"), + TEAM_COMPETENCE("팀 역량", "team_competence"); private final String description; + private final String tag; } \ No newline at end of file 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 c26b05d8..00000000 --- a/src/test/java/starlight/adapter/ai/OpenAiReportGraderTest.java +++ /dev/null @@ -1,100 +0,0 @@ -package starlight.adapter.ai; - -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 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"} - ] - } - """; - - OpenAiGenerator generator = mock(OpenAiGenerator.class); - when(generator.generateReport(content)).thenReturn(llmResponse); - - AiReportResponseParser parser = mock(AiReportResponseParser.class); - AiReportResponse expectedResponse = AiReportResponse.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")) - ); - when(parser.parse(llmResponse)).thenReturn(expectedResponse); - - OpenAiReportGrader sut = new OpenAiReportGrader(generator, parser); - - // when - AiReportResponse result = sut.gradeContent(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 = "{}"; - - OpenAiGenerator generator = mock(OpenAiGenerator.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())); - - OpenAiReportGrader sut = new OpenAiReportGrader(generator, parser); - - // when - sut.gradeContent(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 73858498..00000000 --- a/src/test/java/starlight/adapter/ai/infra/OpenAiGeneratorTest.java +++ /dev/null @@ -1,129 +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.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]"); - - PromptProvider promptProvider = mock(PromptProvider.class); - when(promptProvider.createChecklistGradingPrompt(any(SubSectionType.class), anyString(), anyList(), anyList())) - .thenReturn(mock(Prompt.class)); - - AdvisorProvider advisorProvider = mock(AdvisorProvider.class); - when(advisorProvider.getSimpleLoggerAdvisor()) - .thenReturn(mock(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)); - - OpenAiGenerator sut = new OpenAiGenerator(builder, promptProvider, 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"); - - PromptProvider promptProvider = mock(PromptProvider.class); - when(promptProvider.createChecklistGradingPrompt(any(SubSectionType.class), anyString(), anyList(), anyList())) - .thenReturn(mock(Prompt.class)); - - AdvisorProvider advisorProvider = mock(AdvisorProvider.class); - when(advisorProvider.getSimpleLoggerAdvisor()) - .thenReturn(mock(org.springframework.ai.chat.client.advisor.SimpleLoggerAdvisor.class)); - - OpenAiGenerator sut = new OpenAiGenerator(builder, promptProvider, 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); - - PromptProvider promptProvider = mock(PromptProvider.class); - when(promptProvider.createReportGradingPrompt(anyString())) - .thenReturn(mock(Prompt.class)); - - AdvisorProvider advisorProvider = mock(AdvisorProvider.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); - - String result = sut.generateReport("test content"); - - assertThat(result).isEqualTo(expectedResponse); - } -} 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 22344d18..2b3d8c40 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,8 @@ 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.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/adapter/aireport/infrastructure/webapi/ImageControllerIntegrationTest.java b/src/test/java/starlight/adapter/aireport/infrastructure/webapi/ImageControllerIntegrationTest.java index ef1ce442..80891a04 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/aireport/report/SpringAiReportGraderTest.java b/src/test/java/starlight/adapter/aireport/report/SpringAiReportGraderTest.java new file mode 100644 index 00000000..79f770e2 --- /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 74% rename from src/test/java/starlight/adapter/ai/AiChecklistGraderTest.java rename to src/test/java/starlight/adapter/businessplan/checklist/SpringAiChecklistGraderTest.java index 460483f4..3ca07ca6 100644 --- a/src/test/java/starlight/adapter/ai/AiChecklistGraderTest.java +++ b/src/test/java/starlight/adapter/businessplan/checklist/SpringAiChecklistGraderTest.java @@ -1,9 +1,9 @@ -package starlight.adapter.ai; +package starlight.adapter.businessplan.checklist; 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.agent.SpringAiChecklistAgent; +import starlight.adapter.businessplan.checklist.provider.ChecklistPromptProvider; import starlight.domain.businessplan.enumerate.SubSectionType; import java.util.List; @@ -12,22 +12,23 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; -class AiChecklistGraderTest { +@DisplayName("SpringAiChecklistGrader 테스트") +class SpringAiChecklistGraderTest { @Test @DisplayName("criteria별 컨텍스트를 합치고 LLM 결과를 반환") void check_returnsFromLlm() { - OpenAiGenerator generator = mock(OpenAiGenerator.class); + SpringAiChecklistAgent generator = mock(SpringAiChecklistAgent.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); + SpringAiChecklistAgent generator = mock(SpringAiChecklistAgent.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/businessplan/webapi/SpellControllerTest.java b/src/test/java/starlight/adapter/businessplan/webapi/SpellControllerTest.java index 10abb979..813147fa 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/adapter/expert/webapi/ExpertControllerTest.java b/src/test/java/starlight/adapter/expert/webapi/ExpertControllerTest.java index a4c7a490..42d69a89 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()) diff --git a/src/test/java/starlight/application/aireport/AiReportServiceImplIntegrationTest.java b/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java similarity index 60% rename from src/test/java/starlight/application/aireport/AiReportServiceImplIntegrationTest.java rename to src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java index 98f8b997..5de51af5 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceImplIntegrationTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java @@ -10,16 +10,20 @@ 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.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.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.AiReportCommandPort; +import starlight.application.aireport.required.AiReportQueryPort; +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; @@ -34,12 +38,12 @@ @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) -@Import({AiReportServiceImpl.class, AiReportJpa.class, BusinessPlanJpa.class, AiReportServiceImplIntegrationTest.TestBeans.class}) -@DisplayName("AiReportServiceImpl 통합 테스트") -class AiReportServiceImplIntegrationTest { +@Import({AiReportService.class, AiReportJpa.class, BusinessPlanQueryJpa.class, AiReportServiceIntegrationTest.TestBeans.class}) +@DisplayName("AiReportService 통합 테스트") +class AiReportServiceIntegrationTest { @Autowired - AiReportServiceImpl sut; + AiReportService sut; @Autowired BusinessPlanRepository businessPlanRepository; @Autowired @@ -51,15 +55,53 @@ class AiReportServiceImplIntegrationTest { static class TestBeans { @Bean - AiReportGrader aiReportGrader() { - return content -> { - // 간단한 mock 응답 반환 - return AiReportResponse.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")) - ); + ReportGraderPort aiReportGrader() { + 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") + ) + ); + } }; } @@ -74,92 +116,110 @@ AiReportResponseParser responseParser() { } @Bean - BusinessPlanService businessPlanService(BusinessPlanRepository businessPlanRepository) { - return new BusinessPlanService() { - @Override - public starlight.application.businessplan.provided.dto.BusinessPlanResponse.PreviewPage getBusinessPlanList(Long memberId, org.springframework.data.domain.Pageable pageable) { - throw new UnsupportedOperationException("Not implemented in test"); - } + BusinessPlanCommandPort businessPlanCommandPort(BusinessPlanRepository businessPlanRepository) { + return new BusinessPlanCommandPort() { @Override - public BusinessPlanResponse.Result createBusinessPlan(Long memberId) { - BusinessPlan plan = BusinessPlan.create("default title", memberId); - BusinessPlan saved = businessPlanRepository.save(plan); - return BusinessPlanResponse.Result.from(saved, "Business plan created"); + public BusinessPlan save(BusinessPlan businessPlan) { + return businessPlanRepository.save(businessPlan); } @Override - public BusinessPlanResponse.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"); - } - - @Override - public BusinessPlanResponse.Result getBusinessPlanInfo(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 BusinessPlanResponse.Detail getBusinessPlanDetail(Long planId, 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 String updateBusinessPlanTitle(Long planId, String title, Long memberId) { - throw new UnsupportedOperationException("Not implemented in test"); + public BusinessPlan findByIdWithAllSubSectionsOrThrow(Long id) { + return businessPlanRepository.findByIdWithAllSubSections(id) + .orElseThrow(() -> new RuntimeException("BusinessPlan not found: " + id)); } @Override - public BusinessPlanResponse.Result deleteBusinessPlan(Long planId, 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 + AiReportCommandPort aiReportCommandPort(AiReportRepository aiReportRepository) { + return new AiReportCommandPort() { @Override - public starlight.application.businessplan.provided.dto.SubSectionResponse.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 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 starlight.application.businessplan.provided.dto.SubSectionResponse.Detail getSubSectionDetail( - 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); } + }; + } + @Bean + OcrProviderPort ocrProvider() { + return new OcrProviderPort() { @Override - public List checkAndUpdateSubSection(Long planId, com.fasterxml.jackson.databind.JsonNode jsonNode, - starlight.domain.businessplan.enumerate.SubSectionType subSectionType, Long memberId) { + public starlight.shared.dto.infrastructure.OcrResponse ocrPdfByUrl(String pdfUrl) { throw new UnsupportedOperationException("Not implemented in test"); } @Override - public starlight.application.businessplan.provided.dto.SubSectionResponse.Result deleteSubSection( - Long planId, starlight.domain.businessplan.enumerate.SubSectionType subSectionType, Long memberId) { - throw new UnsupportedOperationException("Not implemented in test"); + public String ocrPdfTextByUrl(String pdfUrl) { + return "PDF에서 추출한 텍스트 내용입니다. 이것은 테스트용 OCR 결과입니다."; } }; } @Bean - OcrProvider ocrProvider() { - return new OcrProvider() { + BusinessPlanContentExtractor businessPlanContentExtractor() { + return new BusinessPlanContentExtractor(); + } + + @Bean + BusinessPlanCommandLookupPort businessPlanCommandLookupPort(BusinessPlanRepository businessPlanRepository) { + return new BusinessPlanCommandLookupPort() { @Override - public starlight.shared.dto.infrastructure.OcrResponse ocrPdfByUrl(String pdfUrl) { - throw new UnsupportedOperationException("Not implemented in test"); + public BusinessPlan save(BusinessPlan plan) { + return businessPlanRepository.save(plan); } @Override - public String ocrPdfTextByUrl(String pdfUrl) { - return "PDF에서 추출한 텍스트 내용입니다. 이것은 테스트용 OCR 결과입니다."; + 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 - BusinessPlanContentExtractor businessPlanContentExtractor() { - return new BusinessPlanContentExtractor(); + BusinessPlanQueryLookupPort businessPlanQueryLookupPort(BusinessPlanRepository businessPlanRepository) { + return new BusinessPlanQueryLookupPort() { + @Override + public BusinessPlan findByIdOrThrow(Long id) { + return businessPlanRepository.findById(id) + .orElseThrow(() -> new RuntimeException("BusinessPlan not found: " + id)); + } + }; } + } /** @@ -213,7 +273,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(); @@ -224,9 +284,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); @@ -252,12 +312,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,16 +348,16 @@ void getAiReport_returnsResponse() { em.clear(); // when - AiReportResponse result = sut.getAiReport(planId, memberId); + AiReportResult result = sut.getAiReport(planId, memberId); // then assertThat(result).isNotNull(); 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 @@ -314,12 +374,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 +401,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(); @@ -352,9 +412,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(); @@ -378,13 +438,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/AiReportServiceUnitTest.java similarity index 60% rename from src/test/java/starlight/application/aireport/AiReportServiceImplUnitTest.java rename to src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java index 64a4cd93..1cc0c191 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceImplUnitTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java @@ -3,22 +3,26 @@ 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.application.aireport.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.AiReportCommandPort; +import starlight.application.aireport.required.OcrProviderPort; +import starlight.application.aireport.required.BusinessPlanCommandLookupPort; +import starlight.application.aireport.required.BusinessPlanQueryLookupPort; import starlight.application.businessplan.util.BusinessPlanContentExtractor; import starlight.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,19 +30,20 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -@DisplayName("AiReportServiceImpl 유닛 테스트") -class AiReportServiceImplUnitTest { +@DisplayName("AiReportService 유닛 테스트") +class AiReportServiceUnitTest { - 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 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); 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를 생성하고 저장한다") @@ -50,19 +55,26 @@ 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 = "사업계획서 내용"; when(contentExtractor.extractContent(plan)).thenReturn(extractedContent); - - AiReportResponse gradingResult = AiReportResponse.fromGradingResult( + + 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, List.of(), List.of(), List.of() ); - when(aiReportGrader.gradeContent(extractedContent)).thenReturn(gradingResult); + when(aiReportGrader.gradeWithSectionAgents(sectionContents, extractedContent)).thenReturn(gradingResult); String rawJson = """ { @@ -79,17 +91,19 @@ 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(businessPlanCommandLookupPort.save(any(BusinessPlan.class))).thenReturn(plan); - sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when - AiReportResponse result = sut.gradeBusinessPlan(planId, memberId); + AiReportResult result = sut.gradeBusinessPlan(planId, memberId); // then assertThat(result).isNotNull(); verify(plan).updateStatus(PlanStatus.AI_REVIEWED); - verify(aiReportQuery).save(any(AiReport.class)); + verify(aiReportCommand).save(any(AiReport.class)); + verify(businessPlanCommandLookupPort).save(plan); } @Test @@ -102,21 +116,28 @@ 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)); String extractedContent = "사업계획서 내용"; when(contentExtractor.extractContent(plan)).thenReturn(extractedContent); - - AiReportResponse gradingResult = AiReportResponse.fromGradingResult( + + 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, List.of(), List.of(), List.of() ); - when(aiReportGrader.gradeContent(extractedContent)).thenReturn(gradingResult); + when(aiReportGrader.gradeWithSectionAgents(sectionContents, extractedContent)).thenReturn(gradingResult); String rawJson = """ { @@ -132,12 +153,13 @@ 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(businessPlanCommandLookupPort.save(any(BusinessPlan.class))).thenReturn(plan); - sut = new AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when - AiReportResponse result = sut.gradeBusinessPlan(planId, memberId); + AiReportResult result = sut.gradeBusinessPlan(planId, memberId); // then assertThat(result).isNotNull(); @@ -154,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 AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) @@ -174,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 AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); + sut = new AiReportService(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when & then assertThatThrownBy(() -> sut.gradeBusinessPlan(planId, memberId)) @@ -195,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 = """ { @@ -214,10 +236,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(businessPlanCommandLookupPort, businessPlanQueryLookupPort, aiReportQuery, aiReportCommand, aiReportGrader, objectMapper, ocrProvider, responseParser, contentExtractor); // when - AiReportResponse result = sut.getAiReport(planId, memberId); + AiReportResult result = sut.getAiReport(planId, memberId); // then assertThat(result).isNotNull(); @@ -235,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 AiReportServiceImpl(businessPlanQuery, businessPlanService, aiReportQuery, 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/adapter/ai/util/AiReportResponseParserTest.java b/src/test/java/starlight/application/aireport/util/AiReportResponseParserTest.java similarity index 94% rename from src/test/java/starlight/adapter/ai/util/AiReportResponseParserTest.java rename to src/test/java/starlight/application/aireport/util/AiReportResponseParserTest.java index 1f5426d4..80fff9ea 100644 --- a/src/test/java/starlight/adapter/ai/util/AiReportResponseParserTest.java +++ b/src/test/java/starlight/application/aireport/util/AiReportResponseParserTest.java @@ -1,9 +1,10 @@ -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.application.aireport.provided.dto.AiReportResponse; +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; @@ -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/application/businessplan/BusinessPlanServiceImplIntegrationTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceIntegrationTest.java similarity index 78% rename from src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.java rename to src/test/java/starlight/application/businessplan/BusinessPlanServiceIntegrationTest.java index 7c076efe..7d951fa5 100644 --- a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplIntegrationTest.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.ChecklistGrader; -import starlight.application.member.required.MemberQueryPort; +import starlight.application.businessplan.required.ChecklistGraderPort; +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,12 +26,12 @@ @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) -@Import({ BusinessPlanServiceImpl.class, BusinessPlanJpa.class, - BusinessPlanServiceImplIntegrationTest.TestBeans.class }) -class BusinessPlanServiceImplIntegrationTest { +@Import({ BusinessPlanService.class, BusinessPlanQueryJpa.class, + BusinessPlanServiceIntegrationTest.TestBeans.class }) +class BusinessPlanServiceIntegrationTest { @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); } @@ -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/BusinessPlanServiceImplUnitTest.java b/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java similarity index 87% rename from src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java rename to src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java index fcc2707a..3fc6c64d 100644 --- a/src/test/java/starlight/application/businessplan/BusinessPlanServiceImplUnitTest.java +++ b/src/test/java/starlight/application/businessplan/BusinessPlanServiceUnitTest.java @@ -9,10 +9,11 @@ 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.BusinessPlanCommandPort; +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; @@ -20,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; @@ -34,22 +35,25 @@ @ExtendWith(MockitoExtension.class) @org.mockito.junit.jupiter.MockitoSettings(strictness = org.mockito.quality.Strictness.LENIENT) -class BusinessPlanServiceImplUnitTest { +class BusinessPlanServiceUnitTest { @Mock - private BusinessPlanQuery businessPlanQuery; + private BusinessPlanCommandPort businessPlanCommand; @Mock - private ChecklistGrader checklistGrader; + private BusinessPlanQueryPort businessPlanQuery; + + @Mock + private ChecklistGraderPort checklistGrader; @Mock private ObjectMapper objectMapper; @Mock - private MemberQueryPort memberQuery; + private MemberLookupPort memberLookupPort; @InjectMocks - private BusinessPlanServiceImpl sut; + private BusinessPlanService sut; private BusinessPlan buildPlanWithSections(Long memberId) { return BusinessPlan.create("default title", memberId); @@ -63,41 +67,41 @@ 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 @DisplayName("사업계획서 생성 시 루트가 저장된다") void createBusinessPlan_savesRoot() { - when(businessPlanQuery.save(any(BusinessPlan.class))) + when(businessPlanCommand.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"); - 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 = "테스트 사업계획서"; 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"); 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 @@ -134,13 +138,13 @@ 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); 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() @@ -165,7 +169,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 @@ -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() @@ -203,11 +207,11 @@ 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"); - verify(businessPlanQuery).save(plan); + verify(businessPlanCommand).save(plan); } @Test @@ -238,7 +242,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); @@ -277,17 +281,17 @@ 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)); - 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); 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 @@ -311,7 +315,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 +341,14 @@ void getBusinessPlanSubSections_returnsExistingSubSectionList() { List.of(false, false, false, false, false)); plan.getProblemRecognition().putSubSection(problem); - when(businessPlanQuery.getOrThrowWithAllSubSections(1L)).thenReturn(plan); + when(businessPlanQuery.findByIdWithAllSubSectionsOrThrow(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 +359,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.findByIdWithAllSubSectionsOrThrow(1L)).thenReturn(plan); org.junit.jupiter.api.Assertions.assertThrows(BusinessPlanException.class, () -> sut.getBusinessPlanDetail(1L, 10L)); @@ -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() @@ -448,13 +452,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"); @@ -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 87% rename from src/test/java/starlight/application/member/CredentialServiceImplIntegrationTest.java rename to src/test/java/starlight/application/member/CredentialServiceIntegrationTest.java index 2cd7ba80..287b389a 100644 --- a/src/test/java/starlight/application/member/CredentialServiceImplIntegrationTest.java +++ b/src/test/java/starlight/application/member/CredentialServiceIntegrationTest.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,15 +17,16 @@ import static org.mockito.Mockito.when; @ExtendWith(SpringExtension.class) -@ContextConfiguration(classes = {CredentialServiceImpl.class, CredentialServiceImplIntegrationTest.TestBeans.class}) -class CredentialServiceImplIntegrationTest { +@ContextConfiguration(classes = {CredentialService.class, CredentialServiceIntegrationTest.TestBeans.class}) +class CredentialServiceIntegrationTest { @TestConfiguration 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/CredentialServiceUnitTest.java similarity index 95% rename from src/test/java/starlight/application/member/CredentialServiceImplUnitTest.java rename to src/test/java/starlight/application/member/CredentialServiceUnitTest.java index 91a936ac..08597bfb 100644 --- a/src/test/java/starlight/application/member/CredentialServiceImplUnitTest.java +++ b/src/test/java/starlight/application/member/CredentialServiceUnitTest.java @@ -14,10 +14,11 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) -class CredentialServiceImplUnitTest { +class CredentialServiceUnitTest { @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/MemberServiceIntegrationTest.java similarity index 94% rename from src/test/java/starlight/application/member/MemberQueryServiceIntegrationTest.java rename to src/test/java/starlight/application/member/MemberServiceIntegrationTest.java index 8a53170f..01a0d724 100644 --- a/src/test/java/starlight/application/member/MemberQueryServiceIntegrationTest.java +++ b/src/test/java/starlight/application/member/MemberServiceIntegrationTest.java @@ -18,10 +18,11 @@ @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) -@Import({MemberQueryService.class, MemberJpa.class}) -class MemberQueryServiceIntegrationTest { +@Import({MemberService.class, MemberJpa.class}) +class MemberServiceIntegrationTest { - @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/MemberServiceUnitTest.java similarity index 96% rename from src/test/java/starlight/application/member/MemberQueryServiceUnitTest.java rename to src/test/java/starlight/application/member/MemberServiceUnitTest.java index dcd8009d..dcfaec2f 100644 --- a/src/test/java/starlight/application/member/MemberQueryServiceUnitTest.java +++ b/src/test/java/starlight/application/member/MemberServiceUnitTest.java @@ -18,11 +18,12 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) -class MemberQueryServiceUnitTest { +class MemberServiceUnitTest { @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 d13032a1..36c868e0 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 d80e9af6..aa4d8a75 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; 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 c45767a8..f45704e2 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` @@ -56,5 +58,21 @@ - Application 입력: `*Input` - Application 출력: `*Result` +## API 응답 규칙 +- 조회 API는 항상 데이터를 반환한다. +- 생성 API는 식별자 또는 핵심 결과만 반환한다. +- 수정/삭제 API는 기본적으로 `ApiResponse.success()`로 통일한다(응답 data 없음). +- 사용자 메시지가 필요한 액션(메일 전송 등)만 메시지 포함 응답을 사용한다. + +## 포맷팅 규칙 +- 컨트롤러 호출이 ~100자 이내면 한 줄로 유지한다. +- 인자가 래핑되면 한 줄에 한 인자로 멀티라인을 유지한다. +- 빌더나 `*Input.of(...)`는 인자가 2개 이상이면 멀티라인을 우선한다. +- 논리 단계별로 빈 줄을 넣어 구분한다(예: 조회 → 계산 → 반환). + +## 도메인 검증 규칙 +- `Assert`는 프로그래머 오류/불변식 위반에만 사용한다. +- 비즈니스 규칙 위반/사용자 입력 오류는 도메인 예외로 처리한다. + ## 로컬 실행 - `./gradlew bootRun --args='--spring.profiles.active=dev'`