Skip to content
Open
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d19dd26
[SRLT-155] Feat: AI 리포트 메일용 PDF html
2ghrms May 17, 2026
77bc0db
[SRLT-155] Feat: AI 리포트 메일용 HTML -> PDF 변환시 디자인 고려하여 수정
2ghrms May 20, 2026
26130c2
[SRLT-155] Feat: HTML to PDF 의존성 추가
2ghrms May 20, 2026
ccf04d7
[SRLT-155] Feat: PDF Renderer 및 폰트 추가
2ghrms May 20, 2026
678603c
[SRLT-155] Feat: PDF Renderer 추가
2ghrms May 20, 2026
5890e63
[SRLT-155] Refactor: SMTP Email Client 커스텀 에러 추가 및 RequiredArg 추가
2ghrms May 20, 2026
07a4bd7
[SRLT-155] Refactor: 개발용 Emailpit 등의 추가를 고려한 EmailConfig의 포트 추가
2ghrms May 20, 2026
57e017d
[SRLT-155] Refactor: event가 application 레이어임에 따라 DTO 명 및 패키지 위치 수정
2ghrms May 20, 2026
6531dd4
[SRLT-155] Refactor: HTML to PDF를 위한 PDF Renderer 및 View Mapper 추가
2ghrms May 20, 2026
7697ad2
[SRLT-155] Refactor: Member Lookup Port 추가
2ghrms May 20, 2026
8d01b91
[SRLT-155] Refactor: PDF 기반 AI 리포트 채점과 이메일 전송 로직을 2개의 이벤트로 분리 / 리스너 계…
2ghrms May 20, 2026
2da4fdf
[SRLT-155] Refactor: AI 리포트 관련 테스트 리팩토링
2ghrms May 20, 2026
a0b852b
[SRLT-155] Fix: 코드래빗 리뷰 반영
2ghrms Jun 18, 2026
67d0e97
[SRLT-155] Fix: 코드래빗 리뷰 반영
2ghrms Jun 18, 2026
992aa88
[SRLT-155] Fix: 코드래빗 리뷰 반영
2ghrms Jun 18, 2026
641e272
Merge branch 'develop' into SRLT-155-ai-리포트-email에-리포트-pdf-업로드
2ghrms Jun 18, 2026
06c66ac
[SRLT-155] Refactor: 폰트파일 캐싱 로직 수정
2ghrms Jun 18, 2026
2b196ac
Merge branch 'SRLT-155-ai-리포트-email에-리포트-pdf-업로드' of https://github.c…
2ghrms Jun 18, 2026
d1c77bb
[SRLT-155] Fix: 테스트 수정
2ghrms Jun 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions deploy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions gradle/spring.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -112,11 +111,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);
} catch (Exception e) {
log.error("[MAIL] AI 리포트 완료 메일 처리 실패 to={}", input.toEmail(), e);
throw new AiReportException(AiReportErrorType.EMAIL_SEND_ERROR);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
package starlight.adapter.shared.infrastructure.pdf;

import com.openhtmltopdf.outputdevice.helper.BaseRendererBuilder;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import com.openhtmltopdf.svgsupport.BatikSVGDrawer;
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;

@Slf4j
@Component
@RequiredArgsConstructor
public class PdfRenderer implements AiReportPdfRenderPort {

private static final String FONT_FAMILY = "PdfReportFont";
private static final List<String> CLASSPATH_FONTS = List.of(
"fonts/NotoSansKR-Regular.ttf"
);
private static final List<Path> 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 final SpringTemplateEngine templateEngine;
private final AiReportPdfViewMapper aiReportPdfViewMapper;

@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 = copyFontToTemp(resource);
if (!isPdfBoxLoadableTrueType(fontFile.toPath())) {
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) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
log.warn("[AI_REPORT_PDF] failed to load classpath font: {}", classpathLocation, e);
return false;
}
}

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 copyFontToTemp(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;
}
}
Loading