diff --git a/deploy/Dockerfile b/deploy/Dockerfile index a01896bd..ff11ed09 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -6,6 +6,7 @@ RUN ./gradlew build -x test # 실행 단계 FROM eclipse-temurin:21-jre-alpine AS runtime +RUN apk add --no-cache font-noto font-noto-cjk ttf-dejavu WORKDIR /app COPY --from=builder /app/build/libs/*.jar app.jar EXPOSE 8080 diff --git a/gradle/spring.gradle b/gradle/spring.gradle index dff83b0a..e73fcc8d 100644 --- a/gradle/spring.gradle +++ b/gradle/spring.gradle @@ -22,4 +22,8 @@ dependencies { // Emailclient implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-mail' + + // HTML to PDF + implementation 'com.openhtmltopdf:openhtmltopdf-pdfbox:1.0.10' + implementation 'com.openhtmltopdf:openhtmltopdf-svg-support:1.0.10' } diff --git a/src/main/java/starlight/adapter/member/persistence/MemberJpa.java b/src/main/java/starlight/adapter/member/persistence/MemberJpa.java index a520a6c7..60696f24 100644 --- a/src/main/java/starlight/adapter/member/persistence/MemberJpa.java +++ b/src/main/java/starlight/adapter/member/persistence/MemberJpa.java @@ -13,7 +13,6 @@ import starlight.application.backoffice.member.required.dto.BackofficeUserSignupLookupResult; import starlight.application.backoffice.order.required.BackofficeOrderMemberLookupPort; import starlight.application.backoffice.order.required.dto.BackofficeOrderMemberLookupResult; -import starlight.application.businessplan.required.MemberLookupPort; import starlight.application.member.required.MemberCommandPort; import starlight.application.member.required.MemberQueryPort; import starlight.domain.member.entity.Member; @@ -28,7 +27,9 @@ @Repository @RequiredArgsConstructor -public class MemberJpa implements MemberQueryPort, MemberCommandPort, MemberLookupPort, +public class MemberJpa implements MemberQueryPort, MemberCommandPort, + starlight.application.businessplan.required.MemberLookupPort, + starlight.application.aireport.required.MemberLookupPort, BackofficeOrderMemberLookupPort, BackofficeBusinessPlanMemberLookupPort, BackofficeUserMemberLookupPort { diff --git a/src/main/java/starlight/adapter/shared/infrastructure/mail/SmtpMailClient.java b/src/main/java/starlight/adapter/shared/infrastructure/mail/SmtpMailClient.java index bf9d859b..26e2e2d7 100644 --- a/src/main/java/starlight/adapter/shared/infrastructure/mail/SmtpMailClient.java +++ b/src/main/java/starlight/adapter/shared/infrastructure/mail/SmtpMailClient.java @@ -2,6 +2,7 @@ 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.core.io.ByteArrayResource; @@ -10,7 +11,7 @@ import org.springframework.stereotype.Component; import org.thymeleaf.context.Context; import org.thymeleaf.spring6.SpringTemplateEngine; -import starlight.application.aireport.AiReportReadyMailInput; +import starlight.application.aireport.event.AiReportReadyMailInput; import starlight.application.aireport.required.AiReportMailPort; import starlight.application.backoffice.mail.provided.dto.input.BackofficeMailSendInput; import starlight.application.backoffice.mail.required.BackofficeMailPort; @@ -19,11 +20,14 @@ import starlight.domain.backoffice.exception.BackofficeErrorType; import starlight.domain.backoffice.exception.BackofficeException; import starlight.domain.backoffice.mail.BackofficeMailContentType; +import starlight.domain.aireport.exception.AiReportErrorType; +import starlight.domain.aireport.exception.AiReportException; import starlight.domain.expertApplication.exception.ExpertApplicationErrorType; import starlight.domain.expertApplication.exception.ExpertApplicationException; @Slf4j @Component +@RequiredArgsConstructor public class SmtpMailClient implements BackofficeMailPort, FeedbackRequestMailPort, AiReportMailPort { @@ -34,11 +38,6 @@ public class SmtpMailClient implements BackofficeMailPort, @Value("${spring.mail.username}") private String senderEmail; - public SmtpMailClient(JavaMailSender javaMailSender, SpringTemplateEngine templateEngine) { - this.javaMailSender = javaMailSender; - this.templateEngine = templateEngine; - } - @Override public void send(BackofficeMailSendInput input, BackofficeMailContentType contentType) { try { @@ -109,11 +108,13 @@ public void sendPdfAiReportReadyMail(AiReportReadyMailInput input) { helper.addAttachment(input.filename(), new ByteArrayResource(input.pdfBytes())); javaMailSender.send(message); - log.info("[MAIL] AI 리포트 완료 메일 발송 to={}", input.toEmail()); + log.info("[MAIL] AI 리포트 완료 메일 발송 to={} attachmentBytes={}", input.toEmail(), input.pdfBytes().length); } catch (MessagingException e) { log.error("[MAIL] AI 리포트 완료 메일 발송 실패 to={}", input.toEmail(), e); + throw new AiReportException(AiReportErrorType.EMAIL_SEND_ERROR, e); } catch (Exception e) { log.error("[MAIL] AI 리포트 완료 메일 처리 실패 to={}", input.toEmail(), e); + throw new AiReportException(AiReportErrorType.EMAIL_SEND_ERROR, e); } } } diff --git a/src/main/java/starlight/adapter/shared/infrastructure/pdf/PdfRenderer.java b/src/main/java/starlight/adapter/shared/infrastructure/pdf/PdfRenderer.java new file mode 100644 index 00000000..ff4eebda --- /dev/null +++ b/src/main/java/starlight/adapter/shared/infrastructure/pdf/PdfRenderer.java @@ -0,0 +1,211 @@ +package starlight.adapter.shared.infrastructure.pdf; + +import com.openhtmltopdf.outputdevice.helper.BaseRendererBuilder; +import com.openhtmltopdf.pdfboxout.PdfRendererBuilder; +import com.openhtmltopdf.svgsupport.BatikSVGDrawer; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType0Font; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; +import starlight.adapter.shared.infrastructure.pdf.mapper.AiReportPdfViewMapper; +import starlight.adapter.shared.infrastructure.pdf.view.AiReportPdfView; +import starlight.application.aireport.provided.dto.AiReportResult; +import starlight.application.aireport.required.AiReportPdfRenderPort; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PdfRenderer implements AiReportPdfRenderPort { + + private static final String FONT_FAMILY = "PdfReportFont"; + private static final List CLASSPATH_FONTS = List.of( + "fonts/NotoSansKR-Regular.ttf" + ); + private static final List SYSTEM_FONTS = List.of( + Path.of("C:\\Windows\\Fonts\\malgun.ttf"), + Path.of("/usr/share/fonts/truetype/nanum/NanumGothic.ttf"), + Path.of("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"), + Path.of("/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc") + ); + private static final ConcurrentHashMap CLASSPATH_FONT_CACHE = new ConcurrentHashMap<>(); + + private final SpringTemplateEngine templateEngine; + private final AiReportPdfViewMapper aiReportPdfViewMapper; + + @PostConstruct + void warmUpClasspathFontCache() { + log.info("[AI_REPORT_PDF] warming up classpath font cache"); + for (String classpathFont : CLASSPATH_FONTS) { + try { + ClassPathResource resource = new ClassPathResource(classpathFont); + if (resource.exists()) { + getCachedClasspathFont(classpathFont, resource); + } + } catch (Exception e) { + log.error("[AI_REPORT_PDF] font warm-up failed: {}", classpathFont, e); + } + } + } + + @Override + public byte[] render(AiReportResult report) { + try { + AiReportPdfView view = aiReportPdfViewMapper.toView(report); + Context context = new Context(); + context.setVariable("totalScore", view.totalScore()); + context.setVariable("sections", view.sections()); + context.setVariable("strengths", view.strengths()); + context.setVariable("weaknesses", view.weaknesses()); + context.setVariable("radarChartSvg", view.radarChartSvg()); + + String html = templateEngine.process("pdf-ai-report", context); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + PdfRendererBuilder builder = new PdfRendererBuilder(); + builder.useFastMode(); + builder.useSVGDrawer(new BatikSVGDrawer()); + configureFont(builder); + builder.withHtmlContent(html, null); + builder.toStream(out); + builder.run(); + return out.toByteArray(); + } catch (Exception e) { + log.error("[AI_REPORT_PDF] html to pdf render failed", e); + throw new IllegalStateException("AI 리포트 PDF 생성에 실패했습니다.", e); + } + } + + private void configureFont(PdfRendererBuilder builder) { + for (String classpathFont : CLASSPATH_FONTS) { + if (registerClasspathFont(builder, classpathFont)) { + return; + } + } + for (Path systemFont : SYSTEM_FONTS) { + if (registerFileFont(builder, systemFont)) { + log.info("[AI_REPORT_PDF] using system font: {}", systemFont); + return; + } + } + throw new IllegalStateException( + "한글 PDF 폰트를 찾을 수 없습니다. classpath(" + CLASSPATH_FONTS + ") 또는 OS 폰트를 확인하세요." + ); + } + + private boolean registerClasspathFont(PdfRendererBuilder builder, String classpathLocation) { + try { + ClassPathResource resource = new ClassPathResource(classpathLocation); + if (!resource.exists()) { + return false; + } + File fontFile = getCachedClasspathFont(classpathLocation, resource); + if (fontFile == null) { + log.warn("[AI_REPORT_PDF] skipping unsupported classpath font: {}", classpathLocation); + return false; + } + builder.useFont(fontFile, FONT_FAMILY, 400, BaseRendererBuilder.FontStyle.NORMAL, true); + return true; + } catch (Exception e) { + log.warn("[AI_REPORT_PDF] failed to load classpath font: {}", classpathLocation, e); + return false; + } + } + + private File getCachedClasspathFont(String classpathLocation, ClassPathResource resource) { + try { + return CLASSPATH_FONT_CACHE.computeIfAbsent(classpathLocation, key -> loadClasspathFont(classpathLocation, resource)); + } catch (FontCacheMissException e) { + return null; + } + } + + private File loadClasspathFont(String classpathLocation, ClassPathResource resource) { + try { + File fontFile = prepareClasspathFontFile(resource); + if (!isPdfBoxLoadableTrueType(fontFile.toPath())) { + log.warn("[AI_REPORT_PDF] unsupported or corrupted font format: {}", classpathLocation); + if (fontFile.exists()) { + fontFile.delete(); + } + throw new FontCacheMissException(); + } + log.info("[AI_REPORT_PDF] successfully cached classpath font: {}", classpathLocation); + return fontFile; + } catch (FontCacheMissException e) { + throw e; + } catch (Exception e) { + log.error("[AI_REPORT_PDF] failed to prepare classpath font file: {}", classpathLocation, e); + throw new FontCacheMissException(e); + } + } + + private static final class FontCacheMissException extends RuntimeException { + private FontCacheMissException() { + super(); + } + + private FontCacheMissException(Throwable cause) { + super(cause); + } + } + + private boolean registerFileFont(PdfRendererBuilder builder, Path fontPath) { + if (!Files.isRegularFile(fontPath) || !isPdfBoxLoadableTrueType(fontPath)) { + return false; + } + try { + builder.useFont(fontPath.toFile(), FONT_FAMILY, 400, BaseRendererBuilder.FontStyle.NORMAL, true); + return true; + } catch (Exception e) { + log.warn("[AI_REPORT_PDF] failed to load system font: {}", fontPath, e); + return false; + } + } + + private boolean isPdfBoxLoadableTrueType(Path fontPath) { + try { + if (!Files.isRegularFile(fontPath)) { + return false; + } + byte[] header = Files.readAllBytes(fontPath); + if (header.length < 4) { + return false; + } + if (header[0] == 'O' && header[1] == 'T' && header[2] == 'T' && header[3] == 'O') { + return false; + } + try (PDDocument document = new PDDocument()) { + PDFont font = PDType0Font.load(document, fontPath.toFile()); + return font != null; + } + } catch (Exception e) { + log.debug("[AI_REPORT_PDF] font not loadable: {}", fontPath, e); + return false; + } + } + + private File prepareClasspathFontFile(ClassPathResource resource) throws Exception { + String suffix = resource.getFilename() != null && resource.getFilename().endsWith(".ttf") + ? ".ttf" + : ".font"; + File temp = Files.createTempFile("pdf-report-font-", suffix).toFile(); + temp.deleteOnExit(); + try (InputStream in = resource.getInputStream()) { + Files.copy(in, temp.toPath(), java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } + return temp; + } +} diff --git a/src/main/java/starlight/adapter/shared/infrastructure/pdf/mapper/AiReportPdfViewMapper.java b/src/main/java/starlight/adapter/shared/infrastructure/pdf/mapper/AiReportPdfViewMapper.java new file mode 100644 index 00000000..aa90e8da --- /dev/null +++ b/src/main/java/starlight/adapter/shared/infrastructure/pdf/mapper/AiReportPdfViewMapper.java @@ -0,0 +1,151 @@ +package starlight.adapter.shared.infrastructure.pdf.mapper; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import starlight.adapter.shared.infrastructure.pdf.view.AiReportPdfView; +import starlight.application.aireport.provided.dto.AiReportResult; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class AiReportPdfViewMapper { + + private static final List SECTION_ORDER = List.of( + new SectionMeta("PROBLEM_RECOGNITION", "문제 정의", "problemRecognitionScore", 20), + new SectionMeta("FEASIBILITY", "실현 가능성", "feasibilityScore", 30), + new SectionMeta("GROWTH_STRATEGY", "성장 전략", "growthStrategyScore", 30), + new SectionMeta("TEAM_COMPETENCE", "팀 역량", "teamCompetenceScore", 20) + ); + + private final ObjectMapper objectMapper; + + public AiReportPdfView toView(AiReportResult aiReportResult) { + List sections = buildSections(aiReportResult); + int problemScore = nullable(aiReportResult.problemRecognitionScore()); + int feasibilityScore = nullable(aiReportResult.feasibilityScore()); + int growthScore = nullable(aiReportResult.growthStrategyScore()); + int teamScore = nullable(aiReportResult.teamCompetenceScore()); + return new AiReportPdfView( + nullable(aiReportResult.totalScore()), + sections, + mapStrengthWeakness(aiReportResult.strengths()), + mapStrengthWeakness(aiReportResult.weaknesses()), + buildRadarSvg(problemScore, feasibilityScore, growthScore, teamScore) + ); + } + + private String buildRadarSvg(int problemScore, int feasibilityScore, int growthScore, int teamScore) { + int cx = 180; + int cy = 110; + int radius = 72; + double[] ratios = { + problemScore / 20.0, + feasibilityScore / 30.0, + growthScore / 30.0, + teamScore / 20.0 + }; + String polygon = String.format( + "%d,%d %d,%d %d,%d %d,%d", + cx, (int) (cy - radius * ratios[0]), + (int) (cx + radius * ratios[1]), cy, + cx, (int) (cy + radius * ratios[2]), + (int) (cx - radius * ratios[3]), cy + ); + + StringBuilder rings = new StringBuilder(); + int[][] ringDefs = {{18, 1}, {36, 0}, {54, 1}, {72, 0}}; + for (int[] ring : ringDefs) { + int r = ring[0]; + String dash = ring[1] == 1 ? " stroke-dasharray=\"4 4\"" : ""; + rings.append(String.format( + "", + cx, cy, r, dash + )); + } + + return """ + + %s + + + + 문제 정의 + 실현 가능성 + 성장 전략 + 팀 역량 + + """.formatted( + rings, + cx, cy - radius, cx, cy + radius, + cx - radius, cy, cx + radius, cy, + polygon, + cx, cy - radius - 16, + cx + radius + 40, cy + 4, + cx, cy + radius + 20, + cx - radius - 40, cy + 4 + ).trim(); + } + + private List buildSections(AiReportResult aiReportResult) { + Map sectionScoreMap = + (aiReportResult.sectionScores() == null ? List.of() : aiReportResult.sectionScores()) + .stream() + .collect(Collectors.toMap( + AiReportResult.SectionScoreDetailResponse::sectionType, + section -> section, + (left, right) -> left + )); + + return SECTION_ORDER.stream() + .map(meta -> { + AiReportResult.SectionScoreDetailResponse section = sectionScoreMap.get(meta.key()); + List checklist = parseChecklist( + section == null ? "[]" : section.gradingListScores() + ); + int score = switch (meta.scoreKey()) { + case "problemRecognitionScore" -> nullable(aiReportResult.problemRecognitionScore()); + case "feasibilityScore" -> nullable(aiReportResult.feasibilityScore()); + case "growthStrategyScore" -> nullable(aiReportResult.growthStrategyScore()); + case "teamCompetenceScore" -> nullable(aiReportResult.teamCompetenceScore()); + default -> 0; + }; + return new AiReportPdfView.SectionView(meta.title(), score, meta.total(), checklist); + }) + .toList(); + } + + private List parseChecklist(String gradingListScores) { + try { + List list = objectMapper.readValue( + gradingListScores == null ? "[]" : gradingListScores, + new TypeReference<>() { + }); + return list.stream() + .filter(item -> item != null && item.item() != null) + .toList(); + } catch (Exception e) { + return List.of(); + } + } + + private List mapStrengthWeakness(List source) { + if (source == null) { + return List.of(); + } + return source.stream() + .map(item -> new AiReportPdfView.StrengthWeakness(item.title(), item.content())) + .toList(); + } + + private static int nullable(Integer score) { + return score == null ? 0 : score; + } + + private record SectionMeta(String key, String title, String scoreKey, int total) { + } +} diff --git a/src/main/java/starlight/adapter/shared/infrastructure/pdf/view/AiReportPdfView.java b/src/main/java/starlight/adapter/shared/infrastructure/pdf/view/AiReportPdfView.java new file mode 100644 index 00000000..8fd0f83f --- /dev/null +++ b/src/main/java/starlight/adapter/shared/infrastructure/pdf/view/AiReportPdfView.java @@ -0,0 +1,32 @@ +package starlight.adapter.shared.infrastructure.pdf.view; + +import java.util.List; + +public record AiReportPdfView( + int totalScore, + List sections, + List strengths, + List weaknesses, + String radarChartSvg +) { + public record SectionView( + String title, + int score, + int total, + List checklist + ) { + } + + public record ChecklistItem( + String item, + Integer score, + Integer maxScore + ) { + } + + public record StrengthWeakness( + String title, + String content + ) { + } +} diff --git a/src/main/java/starlight/application/aireport/AiReportService.java b/src/main/java/starlight/application/aireport/AiReportService.java index 84a37141..358fe703 100644 --- a/src/main/java/starlight/application/aireport/AiReportService.java +++ b/src/main/java/starlight/application/aireport/AiReportService.java @@ -5,15 +5,21 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -import starlight.application.aireport.event.PdfReportRequestedEvent; +import org.springframework.transaction.support.TransactionTemplate; +import starlight.application.aireport.event.AiReportReadyMailInput; +import starlight.application.aireport.event.PdfReportRequestedInput; import starlight.application.aireport.provided.AiReportUseCase; import starlight.application.aireport.provided.dto.AiReportResult; import starlight.application.aireport.required.*; import starlight.application.aireport.util.BusinessPlanContentExtractor; +import starlight.domain.member.entity.Member; import starlight.domain.aireport.entity.AiReport; import starlight.domain.aireport.exception.AiReportErrorType; import starlight.domain.aireport.exception.AiReportException; @@ -40,6 +46,10 @@ public class AiReportService implements AiReportUseCase { private final ObjectMapper objectMapper; private final BusinessPlanContentExtractor contentExtractor; private final ApplicationEventPublisher eventPublisher; + private final AiReportPdfRenderPort aiReportPdfRenderPort; + private final MemberLookupPort memberLookupPort; + private final PlatformTransactionManager transactionManager; + private final ObjectProvider selfProvider; @Value("${ai-report.base-url}") private String aiReportBaseUrl; @@ -96,7 +106,7 @@ public void requestCreateAndGradePdfBusinessPlan(String title, String pdfUrl, Lo log.info("PDF 사업계획서 생성 요청(비동기 채점). title: {}, pdfUrl: {}, memberId: {}", title, pdfUrl, memberId); Long businessPlanId = businessPlanCommandLookupPort.createBusinessPlanWithPdf(title, pdfUrl, memberId); - eventPublisher.publishEvent(new PdfReportRequestedEvent(businessPlanId, pdfUrl, memberId)); + eventPublisher.publishEvent(new PdfReportRequestedInput(businessPlanId, pdfUrl, memberId)); } public void completePdfGrading(Long businessPlanId, String pdfUrl, Long memberId) { @@ -128,6 +138,25 @@ public void completePdfGrading(Long businessPlanId, String pdfUrl, Long memberId aiReportNotificationPort.sendAiReportCompleted(plan.getMemberId(), plan.getId(), plan.getTitle()); } + /** + * PDF 채점 요청 이벤트 처리: 채점 완료 후 완료 메일 이벤트를 발행한다. + */ + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public void handlePdfReportRequested(Long businessPlanId, String pdfUrl, Long memberId) { + try { + selfProvider.getObject().completePdfGrading(businessPlanId, pdfUrl, memberId); + } catch (Exception e) { + log.error("[AI_REPORT_PDF] grading failed. businessPlanId={}, memberId={}", businessPlanId, memberId, e); + return; + } + + try { + selfProvider.getObject().publishAiReportReadyMailEvent(businessPlanId, memberId); + } catch (Exception e) { + log.error("[AI_REPORT_PDF] failed to publish completion mail event. businessPlanId={}", businessPlanId, e); + } + } + @Override @Transactional(readOnly = true) public AiReportResult getAiReport(Long planId, Long memberId) { @@ -140,6 +169,57 @@ public AiReportResult getAiReport(Long planId, Long memberId) { return AiReportResult.from(aiReport); } + /** + * PDF 채점 완료 후 AI 리포트 완료 안내 메일 발송 이벤트를 발행한다. + * PDF 렌더링은 트랜잭션 밖에서 수행한다. + */ + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public void publishAiReportReadyMailEvent(Long planId, Long memberId) { + MailPrepareData mailPrepareData = loadMailPrepareDataInReadOnlyTransaction(planId, memberId); + byte[] pdfBytes = aiReportPdfRenderPort.render(mailPrepareData.aiReportResult()); + + AiReportReadyMailInput mailInput = AiReportReadyMailInput.of( + mailPrepareData.recipientEmail(), + mailPrepareData.recipientName(), + mailPrepareData.reportUrl(), + buildSafePdfFilename(mailPrepareData.planTitle()), + pdfBytes + ); + log.info("[AI_REPORT_PDF] publishing completion mail event. businessPlanId={}, to={}", planId, mailInput.toEmail()); + eventPublisher.publishEvent(mailInput); + } + + private MailPrepareData loadMailPrepareDataInReadOnlyTransaction(Long planId, Long memberId) { + TransactionTemplate readOnlyTemplate = new TransactionTemplate(transactionManager); + readOnlyTemplate.setReadOnly(true); + return readOnlyTemplate.execute(status -> { + AiReportResult aiReportResult = getAiReport(planId, memberId); + Member member = memberLookupPort.findByIdOrThrow(memberId); + BusinessPlan plan = businessPlanQueryLookupPort.findByIdOrThrow(planId); + return new MailPrepareData( + aiReportResult, + member.getEmail(), + member.getName(), + plan.getTitle(), + buildAiReportWebUrl(planId) + ); + }); + } + + private record MailPrepareData( + AiReportResult aiReportResult, + String recipientEmail, + String recipientName, + String planTitle, + String reportUrl + ) { + } + + private String buildSafePdfFilename(String title) { + String safeTitle = title == null ? "business-plan" : title.replaceAll("[\\\\/:*?\"<>|]", "_"); + return safeTitle + ".pdf"; + } + private String getRawJsonStrFromAiReportResult(AiReportResult gradingResult) { JsonNode gradingJsonNode = gradingResult.toJsonNode(); String rawJsonString; diff --git a/src/main/java/starlight/application/aireport/event/AiReportPdfEvaluationEventListener.java b/src/main/java/starlight/application/aireport/event/AiReportPdfEvaluationEventListener.java index 64bf5911..e5675b61 100644 --- a/src/main/java/starlight/application/aireport/event/AiReportPdfEvaluationEventListener.java +++ b/src/main/java/starlight/application/aireport/event/AiReportPdfEvaluationEventListener.java @@ -6,13 +6,7 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionPhase; import org.springframework.transaction.event.TransactionalEventListener; -import starlight.application.aireport.AiReportReadyMailInput; import starlight.application.aireport.AiReportService; -import starlight.application.aireport.required.AiReportMailPort; -import starlight.application.aireport.required.BusinessPlanQueryLookupPort; -import starlight.application.aireport.required.PdfDownloadPort; -import starlight.application.member.required.MemberQueryPort; -import starlight.domain.businessplan.entity.BusinessPlan; @Slf4j @Component @@ -20,34 +14,10 @@ public class AiReportPdfEvaluationEventListener { private final AiReportService aiReportService; - private final MemberQueryPort memberQueryPort; - private final BusinessPlanQueryLookupPort businessPlanQueryLookupPort; - private final PdfDownloadPort pdfDownloadPort; - private final AiReportMailPort aiReportMailPort; @Async("aiReportPdfExecutor") @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void onPdfReportRequested(PdfReportRequestedEvent event) { - long planId = event.businessPlanId(); - try { - aiReportService.completePdfGrading(planId, event.pdfUrl(), event.memberId()); - } catch (Exception e) { - log.error("[AI_REPORT_PDF] grading failed. businessPlanId={}, memberId={}", planId, event.memberId(), e); - return; - } - - try { - var member = memberQueryPort.findByIdOrThrow(event.memberId()); - BusinessPlan plan = businessPlanQueryLookupPort.findByIdOrThrow(planId); - byte[] pdfBytes = pdfDownloadPort.downloadFromUrl(event.pdfUrl()); - String safeTitle = plan.getTitle() == null ? "business-plan" : plan.getTitle().replaceAll("[\\\\/:*?\"<>|]", "_"); - String filename = safeTitle + ".pdf"; - String reportUrl = aiReportService.buildAiReportWebUrl(planId); - aiReportMailPort.sendPdfAiReportReadyMail( - AiReportReadyMailInput.of(member.getEmail(), member.getName(), reportUrl, filename, pdfBytes)); - log.info("[AI_REPORT_PDF] completion mail sent. businessPlanId={}, to={}", planId, member.getEmail()); - } catch (Exception e) { - log.error("[AI_REPORT_PDF] completion mail or PDF download failed. businessPlanId={}", planId, e); - } + public void onPdfReportRequested(PdfReportRequestedInput event) { + aiReportService.handlePdfReportRequested(event.businessPlanId(), event.pdfUrl(), event.memberId()); } } diff --git a/src/main/java/starlight/application/aireport/event/AiReportReadyMailEventListener.java b/src/main/java/starlight/application/aireport/event/AiReportReadyMailEventListener.java new file mode 100644 index 00000000..44a92a41 --- /dev/null +++ b/src/main/java/starlight/application/aireport/event/AiReportReadyMailEventListener.java @@ -0,0 +1,43 @@ +package starlight.application.aireport.event; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +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.aireport.required.AiReportMailPort; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AiReportReadyMailEventListener { + + private final AiReportMailPort aiReportMailPort; + + @Async("emailTaskExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Retryable( + maxAttempts = 3, + backoff = @Backoff(delay = 2000, multiplier = 2), + retryFor = {Exception.class} + ) + public void handleAiReportReadyMailEvent(AiReportReadyMailInput event) { + log.info("[AI_REPORT_PDF] mail listener triggered to={} filename={}", event.toEmail(), event.filename()); + try { + aiReportMailPort.sendPdfAiReportReadyMail(event); + log.info("[AI_REPORT_PDF] completion mail sent to={}", event.toEmail()); + } catch (Exception e) { + log.error("[AI_REPORT_PDF] completion mail failed to={} filename={}", event.toEmail(), event.filename(), e); + throw e; + } + } + + @Recover + public void recoverAiReportReadyMail(Exception e, AiReportReadyMailInput event) { + log.error("[AI_REPORT_PDF FINAL FAILURE] to={} filename={}", event.toEmail(), event.filename(), e); + } +} diff --git a/src/main/java/starlight/application/aireport/AiReportReadyMailInput.java b/src/main/java/starlight/application/aireport/event/AiReportReadyMailInput.java similarity index 93% rename from src/main/java/starlight/application/aireport/AiReportReadyMailInput.java rename to src/main/java/starlight/application/aireport/event/AiReportReadyMailInput.java index f903c1ee..7c9c22b9 100644 --- a/src/main/java/starlight/application/aireport/AiReportReadyMailInput.java +++ b/src/main/java/starlight/application/aireport/event/AiReportReadyMailInput.java @@ -1,4 +1,4 @@ -package starlight.application.aireport; +package starlight.application.aireport.event; /** * AI 리포트(PDF) 완료 안내 메일 발송에 필요한 입력. diff --git a/src/main/java/starlight/application/aireport/event/PdfReportRequestedEvent.java b/src/main/java/starlight/application/aireport/event/PdfReportRequestedInput.java similarity index 54% rename from src/main/java/starlight/application/aireport/event/PdfReportRequestedEvent.java rename to src/main/java/starlight/application/aireport/event/PdfReportRequestedInput.java index 5d73428d..72ffef21 100644 --- a/src/main/java/starlight/application/aireport/event/PdfReportRequestedEvent.java +++ b/src/main/java/starlight/application/aireport/event/PdfReportRequestedInput.java @@ -1,4 +1,4 @@ package starlight.application.aireport.event; -public record PdfReportRequestedEvent(long businessPlanId, String pdfUrl, long memberId) { +public record PdfReportRequestedInput(long businessPlanId, String pdfUrl, long memberId) { } diff --git a/src/main/java/starlight/application/aireport/required/AiReportMailPort.java b/src/main/java/starlight/application/aireport/required/AiReportMailPort.java index fbaafa98..fca43bc4 100644 --- a/src/main/java/starlight/application/aireport/required/AiReportMailPort.java +++ b/src/main/java/starlight/application/aireport/required/AiReportMailPort.java @@ -1,6 +1,6 @@ package starlight.application.aireport.required; -import starlight.application.aireport.AiReportReadyMailInput; +import starlight.application.aireport.event.AiReportReadyMailInput; public interface AiReportMailPort { diff --git a/src/main/java/starlight/application/aireport/required/AiReportPdfRenderPort.java b/src/main/java/starlight/application/aireport/required/AiReportPdfRenderPort.java new file mode 100644 index 00000000..4c7180bc --- /dev/null +++ b/src/main/java/starlight/application/aireport/required/AiReportPdfRenderPort.java @@ -0,0 +1,8 @@ +package starlight.application.aireport.required; + +import starlight.application.aireport.provided.dto.AiReportResult; + +public interface AiReportPdfRenderPort { + + byte[] render(AiReportResult report); +} diff --git a/src/main/java/starlight/application/aireport/required/MemberLookupPort.java b/src/main/java/starlight/application/aireport/required/MemberLookupPort.java new file mode 100644 index 00000000..430462fb --- /dev/null +++ b/src/main/java/starlight/application/aireport/required/MemberLookupPort.java @@ -0,0 +1,7 @@ +package starlight.application.aireport.required; + +import starlight.domain.member.entity.Member; + +public interface MemberLookupPort { + Member findByIdOrThrow(Long id); +} diff --git a/src/main/java/starlight/bootstrap/MailConfig.java b/src/main/java/starlight/bootstrap/MailConfig.java index 027954bc..48f968e9 100644 --- a/src/main/java/starlight/bootstrap/MailConfig.java +++ b/src/main/java/starlight/bootstrap/MailConfig.java @@ -6,6 +6,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.util.StringUtils; import java.util.Properties; @@ -17,21 +18,25 @@ public class MailConfig { public JavaMailSender javaMailSender(MailProperties mailProperties) { JavaMailSenderImpl sender = new JavaMailSenderImpl(); sender.setHost(mailProperties.getHost()); - sender.setPort(mailProperties.getPort()); + sender.setPort(resolveSmtpPort(mailProperties)); sender.setUsername(mailProperties.getUsername()); sender.setPassword(mailProperties.getPassword()); - sender.setJavaMailProperties(buildJavaMailProps(mailProperties)); // ⬅ 추출된 메서드 사용 + sender.setJavaMailProperties(buildJavaMailProps(mailProperties)); return sender; } private Properties buildJavaMailProps(MailProperties mailProperties) { Properties props = new Properties(); props.put("mail.transport.protocol", "smtp"); - props.put("mail.smtp.auth", "true"); - if (mailProperties.getPort() == 465) { + boolean smtpAuth = StringUtils.hasText(mailProperties.getUsername()) + && StringUtils.hasText(mailProperties.getPassword()); + props.put("mail.smtp.auth", String.valueOf(smtpAuth)); + + int port = resolveSmtpPort(mailProperties); + if (port == 465) { props.put("mail.smtp.ssl.enable", "true"); - } else { + } else if (port == 587) { props.put("mail.smtp.starttls.enable", "true"); props.put("mail.smtp.starttls.required", "true"); } @@ -43,4 +48,8 @@ private Properties buildJavaMailProps(MailProperties mailProperties) { props.putAll(mailProperties.getProperties()); return props; } + + private int resolveSmtpPort(MailProperties mailProperties) { + return mailProperties.getPort() != null ? mailProperties.getPort() : 587; + } } diff --git a/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java b/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java index 912baf9c..f524a6ad 100644 --- a/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java +++ b/src/main/java/starlight/domain/aireport/exception/AiReportErrorType.java @@ -15,7 +15,8 @@ public enum AiReportErrorType implements ErrorType { 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 리포트 에이전트가 중복입니다."); + AI_AGENT_DUPLICATED(HttpStatus.INTERNAL_SERVER_ERROR, "AI 리포트 에이전트가 중복입니다."), + EMAIL_SEND_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "AI 리포트 이메일 전송 중에 오류가 발생했습니다."); ; private final HttpStatus status; diff --git a/src/main/resources/fonts/NotoSansKR-Regular.ttf b/src/main/resources/fonts/NotoSansKR-Regular.ttf new file mode 100644 index 00000000..b386890b Binary files /dev/null and b/src/main/resources/fonts/NotoSansKR-Regular.ttf differ diff --git a/src/main/resources/templates/pdf-ai-report.html b/src/main/resources/templates/pdf-ai-report.html new file mode 100644 index 00000000..899fadcf --- /dev/null +++ b/src/main/resources/templates/pdf-ai-report.html @@ -0,0 +1,456 @@ + + + + + 사업계획서 AI 리포트 + + + +
+
사업계획서 AI 리포트
+ +
+ + + + + +
+
+ + + + + +
총점0점
+
+ + + + + + + + +
+
문제 정의
+
+ 0점 + / 0점 +
+
+
실현 가능성
+
+ 0점 + / 0점 +
+
+
+
+ +

영역별 상세 평가

+ + + + + + + + + + +
+ + + + + +
문제 정의0/0점
+ + + + + + + + +
세부 항목 없음
+ 1 + 세부 항목 + + 0/0 +
+
+ + + + + +
실현 가능성0/0점
+ + + + + + + + +
세부 항목 없음
+ 1 + 세부 항목 + + 0/0 +
+
+ + + + + +
성장 전략0/0점
+ + + + + + + + +
세부 항목 없음
+ 1 + 세부 항목 + + 0/0 +
+
+ + + + + +
팀 역량0/0점
+ + + + + + + + +
세부 항목 없음
+ 1 + 세부 항목 + + 0/0 +
+
+ +
+
총평
+ + + + + +
+
강점
+
+

강점 없음

+

분석된 강점 데이터가 없습니다.

+
+
+

강점 제목

+

강점 내용

+
+
+
약점
+
+

약점 없음

+

분석된 약점 데이터가 없습니다.

+
+
+

약점 제목

+

약점 내용

+
+
+
+
+ + diff --git a/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java b/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java index 51afff57..782d4c91 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceIntegrationTest.java @@ -9,24 +9,32 @@ import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.test.context.event.ApplicationEvents; +import org.springframework.test.context.event.RecordApplicationEvents; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; +import org.springframework.core.task.SyncTaskExecutor; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; import starlight.adapter.aireport.report.parser.AiReportResponseParser; import starlight.adapter.aireport.persistence.AiReportJpa; import starlight.adapter.aireport.persistence.AiReportRepository; import starlight.adapter.businessplan.persistence.BusinessPlanQueryJpa; import starlight.adapter.businessplan.persistence.BusinessPlanRepository; -import starlight.application.aireport.event.AiReportPdfEvaluationEventListener; -import starlight.application.aireport.event.PdfReportRequestedEvent; +import starlight.application.aireport.event.AiReportReadyMailEventListener; +import starlight.application.aireport.event.AiReportReadyMailInput; import starlight.application.aireport.provided.dto.AiReportResult; import starlight.application.aireport.required.AiReportCommandPort; import starlight.application.aireport.required.AiReportMailPort; +import starlight.application.aireport.required.AiReportPdfRenderPort; import starlight.application.aireport.required.AiReportNotificationPort; import starlight.application.aireport.required.AiReportQueryPort; import starlight.application.aireport.required.OcrProviderPort; import starlight.application.aireport.required.PdfDownloadPort; import starlight.application.aireport.required.ReportGraderPort; -import starlight.application.member.required.MemberQueryPort; +import starlight.application.aireport.required.MemberLookupPort; import starlight.application.businessplan.required.BusinessPlanCommandPort; import starlight.application.businessplan.required.BusinessPlanQueryPort; import starlight.application.aireport.required.BusinessPlanCommandLookupPort; @@ -41,15 +49,16 @@ import java.util.List; import java.util.Optional; +import java.util.concurrent.Executor; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.verify; @DataJpaTest +@RecordApplicationEvents @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.ANY) -@Import({AiReportService.class, AiReportJpa.class, BusinessPlanQueryJpa.class, AiReportPdfEvaluationEventListener.class, AiReportServiceIntegrationTest.TestBeans.class}) +@Import({AiReportService.class, AiReportJpa.class, BusinessPlanQueryJpa.class, AiReportReadyMailEventListener.class, AiReportServiceIntegrationTest.TestBeans.class}) @DisplayName("AiReportService 통합 테스트") class AiReportServiceIntegrationTest { @@ -62,10 +71,15 @@ class AiReportServiceIntegrationTest { @Autowired EntityManager em; @Autowired - AiReportPdfEvaluationEventListener aiReportPdfEvaluationEventListener; + AiReportReadyMailEventListener aiReportReadyMailEventListener; @Autowired AiReportMailPort aiReportMailPort; + @Autowired + ApplicationEvents applicationEvents; + @Autowired + PlatformTransactionManager transactionManager; + @EnableAsync @TestConfiguration static class TestBeans { @@ -246,14 +260,29 @@ PdfDownloadPort pdfDownloadPort() { return url -> new byte[]{0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x0A}; } + @Bean(name = "emailTaskExecutor") + Executor emailTaskExecutor() { + return new SyncTaskExecutor(); + } + + @Bean(name = "aiReportPdfExecutor") + Executor aiReportPdfExecutor() { + return new SyncTaskExecutor(); + } + @Bean AiReportMailPort aiReportMailPort() { return Mockito.mock(AiReportMailPort.class); } @Bean - MemberQueryPort memberQueryPort() { - MemberQueryPort port = Mockito.mock(MemberQueryPort.class); + AiReportPdfRenderPort aiReportPdfRenderPort() { + return report -> new byte[]{0x25, 0x50, 0x44, 0x46, 0x2D, 0x31, 0x2E, 0x0A}; + } + + @Bean + MemberLookupPort memberLookupPort() { + MemberLookupPort port = Mockito.mock(MemberLookupPort.class); Member member = Mockito.mock(Member.class); Mockito.when(member.getEmail()).thenReturn("tester@example.com"); Mockito.when(member.getName()).thenReturn("테스트회원"); @@ -301,14 +330,29 @@ private void createAllSubSections(BusinessPlan plan) { } /** - * 프로덕션에서는 커밋 후 이벤트로 비동기 처리되지만, 슬라이스 테스트에서는 리스너를 직접 호출한다. - * {@code @Transactional} 테스트 롤백 시 {@code publishEvent}의 AFTER_COMMIT 리스너는 실행되지 않는다. + * 프로덕션에서는 커밋 후 {@link TransactionalEventListener}(AFTER_COMMIT)로 비동기 처리된다. + * 슬라이스 테스트에서는 사업계획서 생성만 REQUIRES_NEW로 커밋한 뒤, + * {@link AiReportService#handlePdfReportRequested}를 직접 호출하고 메일 이벤트만 수동 디스패치한다. */ private void runPdfEvaluationPipeline(String title, String pdfUrl, Long memberId) { - sut.requestCreateAndGradePdfBusinessPlan(title, pdfUrl, memberId); - em.flush(); - Long planId = businessPlanRepository.findAllByMemberIdOrderByLastSavedAt(memberId).get(0).getId(); - aiReportPdfEvaluationEventListener.onPdfReportRequested(new PdfReportRequestedEvent(planId, pdfUrl, memberId)); + TransactionTemplate commitTemplate = new TransactionTemplate(transactionManager); + commitTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + Long planId = commitTemplate.execute(status -> { + sut.requestCreateAndGradePdfBusinessPlan(title, pdfUrl, memberId); + em.flush(); + return businessPlanRepository.findAllByMemberIdOrderByLastSavedAt(memberId).get(0).getId(); + }); + sut.handlePdfReportRequested(planId, pdfUrl, memberId); + dispatchRecordedAiReportReadyMailEvents(); + } + + /** + * 메일 이벤트는 NOT_SUPPORTED 컨텍스트에서 발행되어 AFTER_COMMIT 리스너가 슬라이스 테스트에서 + * 자동 실행되지 않을 수 있으므로, 기록된 이벤트를 직접 디스패치한다. + */ + private void dispatchRecordedAiReportReadyMailEvents() { + applicationEvents.stream(AiReportReadyMailInput.class) + .forEach(aiReportReadyMailEventListener::handleAiReportReadyMailEvent); } @Test @@ -377,7 +421,9 @@ void gradeBusinessPlan_updatesExistingReport() { assertThat(secondResult.businessPlanId()).isEqualTo(planId); // DB에 하나만 존재하는지 확인 - List reports = aiReportRepository.findAll(); + List reports = aiReportRepository.findAll().stream() + .filter(report -> report.getBusinessPlanId().equals(planId)) + .toList(); assertThat(reports).hasSize(1); } diff --git a/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java b/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java index f6bfcbb2..d4b6fd00 100644 --- a/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java +++ b/src/test/java/starlight/application/aireport/AiReportServiceUnitTest.java @@ -3,14 +3,18 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.transaction.PlatformTransactionManager; import starlight.adapter.aireport.report.parser.AiReportResponseParser; import starlight.application.aireport.provided.dto.AiReportResult; import starlight.application.aireport.required.ReportGraderPort; import starlight.application.aireport.required.AiReportQueryPort; import starlight.application.aireport.required.AiReportCommandPort; +import starlight.application.aireport.required.MemberLookupPort; import starlight.application.aireport.required.AiReportNotificationPort; import starlight.application.aireport.required.OcrProviderPort; +import starlight.application.aireport.required.AiReportPdfRenderPort; import starlight.application.aireport.required.BusinessPlanCommandLookupPort; import starlight.application.aireport.required.BusinessPlanQueryLookupPort; import starlight.application.aireport.util.BusinessPlanContentExtractor; @@ -46,9 +50,32 @@ class AiReportServiceUnitTest { private final AiReportResponseParser responseParser = new AiReportResponseParser(objectMapper); private final BusinessPlanContentExtractor contentExtractor = mock(BusinessPlanContentExtractor.class); private final ApplicationEventPublisher eventPublisher = mock(ApplicationEventPublisher.class); + private final AiReportPdfRenderPort aiReportPdfRenderPort = mock(AiReportPdfRenderPort.class); + private final MemberLookupPort memberLookupPort = mock(MemberLookupPort.class); + private final PlatformTransactionManager transactionManager = mock(PlatformTransactionManager.class); private AiReportService sut; + @SuppressWarnings("unchecked") + private AiReportService createSut() { + return new AiReportService( + businessPlanCommandLookupPort, + businessPlanQueryLookupPort, + aiReportQuery, + aiReportCommand, + aiReportGrader, + ocrProvider, + aiReportNotificationPort, + objectMapper, + contentExtractor, + eventPublisher, + aiReportPdfRenderPort, + memberLookupPort, + transactionManager, + mock(ObjectProvider.class) + ); + } + @Test @DisplayName("채점 성공 시 새로운 AiReport를 생성하고 저장한다") void gradeBusinessPlan_createsNewReport() { @@ -278,19 +305,4 @@ void getAiReport_throwsExceptionWhenNotFound() { .extracting("errorType") .isEqualTo(AiReportErrorType.AI_REPORT_NOT_FOUND); } - - private AiReportService createSut() { - return new AiReportService( - businessPlanCommandLookupPort, - businessPlanQueryLookupPort, - aiReportQuery, - aiReportCommand, - aiReportGrader, - ocrProvider, - aiReportNotificationPort, - objectMapper, - contentExtractor, - eventPublisher - ); - } }