Skip to content
Merged
6 changes: 0 additions & 6 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,6 @@ dependencies {

implementation 'org.springframework.ai:spring-ai-pdf-document-reader'

implementation 'org.springframework.boot:spring-boot-starter-amqp'
implementation 'org.springframework.amqp:spring-rabbit-stream'
compileOnly 'org.projectlombok:lombok'

// mysql jdbc
Expand All @@ -53,7 +51,6 @@ dependencies {

// test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.amqp:spring-rabbit-test'
testImplementation 'org.springframework.batch:spring-batch-test'
testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
Expand Down Expand Up @@ -92,9 +89,6 @@ dependencies {
// JSON
implementation("org.json:json:20240303")

// acurator
implementation 'org.springframework.boot:spring-boot-starter-actuator'

// websocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.haru.api.domain.moodTracker.service;

import com.haru.api.domain.snsEvent.entity.enums.Format;
import com.haru.api.global.util.file.FileConvertHelper;
import com.haru.api.infra.api.dto.SurveyReportResponse;
import com.haru.api.domain.moodTracker.entity.*;
import com.haru.api.domain.moodTracker.repository.*;
Expand All @@ -10,25 +11,23 @@
import com.haru.api.infra.s3.AmazonS3Manager;
import com.haru.api.infra.s3.MarkdownFileUploader;
import com.lowagie.text.pdf.BaseFont;
import com.lowagie.text.pdf.PdfWriter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.xwpf.usermodel.ParagraphAlignment;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import com.lowagie.text.Document;
import com.lowagie.text.Font;
import com.lowagie.text.Paragraph;
import org.thymeleaf.spring6.SpringTemplateEngine;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.net.URL;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.io.InputStream;
import java.util.*;
import java.util.stream.Collectors;
import org.thymeleaf.context.Context;

import static com.haru.api.global.apiPayload.code.status.ErrorStatus.*;
import static com.haru.api.global.apiPayload.code.status.ErrorStatus.MOOD_TRACKER_DOWNLOAD_ERROR;
Expand All @@ -47,6 +46,8 @@ public class MoodTrackerReportServiceImpl implements MoodTrackerReportService {

private final AmazonS3Manager amazonS3Manager;
private final MarkdownFileUploader markdownFileUploader;
private final SpringTemplateEngine templateEngine;
private final FileConvertHelper fileConvertHelper;

@Async
public void generateReport(Long moodTrackerId) {
Expand Down Expand Up @@ -211,72 +212,48 @@ public void generateAndUploadReportFileAndThumbnail(Long moodTrackerId){

byte[] pdfReportBytes;
byte[] docxReportBytes;

try {
// 폰트 경로
URL resource = getClass().getClassLoader().getResource("templates/NotoSansKR-Regular.ttf");
File reg = new File(resource.toURI());
try (ByteArrayOutputStream pdfOut = new ByteArrayOutputStream()) {
Document document = new Document();
PdfWriter.getInstance(document, pdfOut);
document.open();

// 한글 폰트 지정
BaseFont baseFont = BaseFont.createFont(reg.getAbsolutePath(), BaseFont.IDENTITY_H, BaseFont.EMBEDDED);
Font font = new Font(baseFont, 12);

document.add(new Paragraph("Mood Tracker Report", font));
document.add(new Paragraph("제목: " + foundMoodTracker.getTitle(), font));
document.add(new Paragraph("작성자: " + foundMoodTracker.getCreator().getName(), font));
document.add(new Paragraph("마감일: " + foundMoodTracker.getDueDate(), font));
document.add(new Paragraph("리포트 내용: " + foundMoodTracker.getReport(), font));

document.close();
pdfReportBytes = pdfOut.toByteArray();
Resource fontRes = new ClassPathResource("templates/NotoSansKR-Regular.ttf");
if (!fontRes.exists()) {
throw new IllegalStateException("Font not found: templates/NotoSansKR-Regular.ttf");
}

// ====== DOCX 생성 (Apache POI) ======
try (ByteArrayOutputStream docxOut = new ByteArrayOutputStream()) {
XWPFDocument doc = new XWPFDocument();

// 제목
XWPFParagraph titlePara = doc.createParagraph();
XWPFRun titleRun = titlePara.createRun();
titleRun.setText("Mood Tracker Report");
titleRun.setBold(true);
titleRun.setFontFamily("Noto Sans KR");
titleRun.setFontSize(14);

// 내용
XWPFParagraph contentPara = doc.createParagraph();
XWPFRun contentRun = contentPara.createRun();
contentRun.setText("제목: " + foundMoodTracker.getTitle());
contentRun.addBreak();
contentRun.setText("작성자: " + foundMoodTracker.getCreator().getName());
contentRun.addBreak();
contentRun.setText("마감일: " + foundMoodTracker.getDueDate());
contentRun.addBreak();
contentRun.setText("리포트 내용: " + foundMoodTracker.getReport());

doc.write(docxOut);
docxReportBytes = docxOut.toByteArray();
byte[] fontBytes;
try (InputStream fin = fontRes.getInputStream()) {
fontBytes = fin.readAllBytes();
}

// PDF: 제목 + 메타(작성자/마감일) + 본문 마크다운
pdfReportBytes = createMoodTrackerPDFFromMarkdown(
foundMoodTracker.getTitle(),
foundMoodTracker.getCreator() != null ? foundMoodTracker.getCreator().getName() : null,
foundMoodTracker.getDueDate(),
foundMoodTracker.getReport(),
fontBytes
);

// DOCX: 제목 + 메타(작성자/마감일) + 본문 마크다운
docxReportBytes = createMoodTrackerDocxFromMarkdown(
foundMoodTracker.getTitle(),
foundMoodTracker.getCreator() != null ? foundMoodTracker.getCreator().getName() : null,
foundMoodTracker.getDueDate(),
foundMoodTracker.getReport()
);

} catch (Exception e) {
log.error("Error creating document: {}", e.getMessage());
log.error("Error creating document", e);
throw new MoodTrackerHandler(MOOD_TRACKER_DOWNLOAD_ERROR);
}
// PDF, DOCS파일, 썸네일 S3에 업로드 및 DB에 keyName저장

String fullPath = "mood-tracker/" + moodTrackerId;
String pdfReportKey = amazonS3Manager.generateKeyName(fullPath) + "." + "pdf";
String wordReportKey = amazonS3Manager.generateKeyName(fullPath) + "." + "docx";
String pdfReportKey = amazonS3Manager.generateKeyName(fullPath) + ".pdf";
String wordReportKey = amazonS3Manager.generateKeyName(fullPath) + ".docx";

amazonS3Manager.uploadFile(pdfReportKey, pdfReportBytes, "application/pdf");
amazonS3Manager.uploadFile(wordReportKey, docxReportBytes, "application/vnd.openxmlformats-officedocument.wordprocessingml.document");
// 분위기 트래커에 keyName 저장
foundMoodTracker.updateReportKeyName(
pdfReportKey,
wordReportKey
);
// 분위기 트래커 리포트의 PDF 첫페이지를 썸네일로 저장

foundMoodTracker.updateReportKeyName(pdfReportKey, wordReportKey);

String thumbnailKey = markdownFileUploader.createOrUpdateThumbnailWithPdfBytes(
pdfReportBytes,
"mood-tracker",
Expand All @@ -285,10 +262,102 @@ public void generateAndUploadReportFileAndThumbnail(Long moodTrackerId){
foundMoodTracker.updateThumbnailKey(thumbnailKey);
}

// 파일명 인코딩
private String buildEncodedContentDisposition(String originalFilename) {
String encodedFilename = URLEncoder.encode(originalFilename, StandardCharsets.UTF_8)
.replaceAll("\\+", "%20"); // 공백 처리
return "attachment; filename*=UTF-8''" + encodedFilename;
/* ========================= 헬퍼들 ========================= */

// PDF: 제목 + 메타(작성자/마감일) + 본문(마크다운) → 템플릿 렌더링 후 HTML→PDF 변환
private byte[] createMoodTrackerPDFFromMarkdown(
String title,
String creator,
Object dueDate,
String markdown,
byte[] fontBytes
) {
try {
// 템플릿 변수 구성
Context ctx = new Context(java.util.Locale.KOREA);
ctx.setVariable("title", (title == null || title.isBlank()) ? "팀 분위기 조사" : title);
ctx.setVariable("creator", creator);
ctx.setVariable("dueDate", fileConvertHelper.formatDueDate(dueDate));
ctx.setVariable("reportHtml", fileConvertHelper.markdownToHtml(markdown));

// 템플릿 렌더링
String html = templateEngine.process("mood-tracker-report-template", ctx);

// 스타일 주입
html = fileConvertHelper.injectStyle(html);

// HTML → PDF 변환 (OpenHTMLtoPDF 사용, 'NotoSansKR'로 등록)
return fileConvertHelper.convertHtmlToPdf(html, fontBytes);
} catch (Exception e) {
log.error("createMoodTrackerPDFFromMarkdown failed", e);
throw new MoodTrackerHandler(MOOD_TRACKER_DOWNLOAD_ERROR);
}
}

// DOCX 생성: 제목 + 메타 + 마크다운 본문
private byte[] createMoodTrackerDocxFromMarkdown(
String title,
String creator,
Object dueDate,
String markdown
) throws Exception {
try (ByteArrayOutputStream out = new ByteArrayOutputStream();
XWPFDocument doc = new XWPFDocument()) {

// 제목
XWPFParagraph t = doc.createParagraph();
t.setAlignment(ParagraphAlignment.CENTER);
XWPFRun tr = t.createRun();
tr.setFontFamily("Noto Sans KR");
tr.setText(title != null ? title : "Mood Tracker Report");
tr.setBold(true);
tr.setFontSize(22);
tr.addBreak();

// 메타(작성자 · 마감일)
String metaCreator = (creator != null && !creator.isBlank()) ? ("작성자: " + creator) : null;
String metaDue = fileConvertHelper.formatDueDate(dueDate);
if (metaCreator != null || metaDue != null) {
XWPFParagraph meta = doc.createParagraph();
meta.setAlignment(ParagraphAlignment.CENTER);
XWPFRun mr = meta.createRun();
mr.setFontSize(12);
StringBuilder sb = new StringBuilder();
if (metaCreator != null) sb.append(metaCreator);
if (metaCreator != null && metaDue != null) sb.append(" / ");
if (metaDue != null) sb.append("마감일: ").append(metaDue);
mr.setText(sb.toString());
mr.setFontFamily("Noto Sans KR");
mr.addBreak();
}

// 본문(마크다운 경량 파서)
if (markdown == null) markdown = "";
String[] lines = markdown.replace("\r\n", "\n").split("\n");

for (String raw : lines) {
String line = raw.trim();

if (line.startsWith("### ")) {
// 시그니처 예) addHeading(XWPFDocument, String, int, String wordFontFamily)
fileConvertHelper.addHeading(doc, line.substring(4), 14, "Noto Sans KR");
} else if (line.startsWith("## ")) {
fileConvertHelper.addHeading(doc, line.substring(3), 16, "Noto Sans KR");
} else if (line.startsWith("# ")) {
fileConvertHelper.addHeading(doc, line.substring(2), 18, "Noto Sans KR");
} else if (line.startsWith("- ")) {
// 예) addDocsBullet(XWPFDocument, String, String wordFontFamily)
fileConvertHelper.addDocsBullet(doc, line.substring(2), "Noto Sans KR");
} else if (line.matches("^\\d+\\.\\s+.*")) {
fileConvertHelper.addDocsBullet(doc, line, "Noto Sans KR");
} else {
// 예) addParagraph(XWPFDocument, String, int, boolean, String wordFontFamily)
fileConvertHelper.addParagraph(doc, line, 12, false, "Noto Sans KR");
}
}

doc.write(out);
return out.toByteArray();
}
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
package com.haru.api.domain.snsEvent.service;

import com.haru.api.domain.lastOpened.entity.UserDocumentId;
import com.haru.api.domain.lastOpened.entity.UserDocumentLastOpened;
import com.haru.api.domain.lastOpened.entity.enums.DocumentType;
import com.haru.api.domain.lastOpened.repository.UserDocumentLastOpenedRepository;
import com.haru.api.domain.lastOpened.service.UserDocumentLastOpenedService;
import com.haru.api.domain.snsEvent.converter.SnsEventConverter;
Expand All @@ -24,17 +21,15 @@
import com.haru.api.domain.userWorkspace.repository.UserWorkspaceRepository;
import com.haru.api.domain.workspace.entity.Workspace;
import com.haru.api.domain.workspace.repository.WorkspaceRepository;
import com.haru.api.global.apiPayload.code.status.ErrorStatus;
import com.haru.api.global.apiPayload.exception.handler.MemberHandler;
import com.haru.api.global.apiPayload.exception.handler.SnsEventHandler;
import com.haru.api.global.apiPayload.exception.handler.UserDocumentLastOpenedHandler;
import com.haru.api.global.apiPayload.exception.handler.WorkspaceHandler;
import com.haru.api.global.util.file.FileConvertHelper;
import com.haru.api.infra.api.restTemplate.InstagramOauth2RestTemplate;
import com.haru.api.infra.s3.AmazonS3Manager;
import com.haru.api.infra.s3.MarkdownFileUploader;
import com.lowagie.text.Element;
import com.lowagie.text.pdf.*;
import com.openhtmltopdf.pdfboxout.PdfRendererBuilder;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -49,10 +44,6 @@
import org.thymeleaf.spring6.SpringTemplateEngine;

import java.io.*;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.*;
Expand Down Expand Up @@ -85,6 +76,7 @@ public class SnsEventCommandServiceImpl implements SnsEventCommandService{
private final int PER_COL = WORD_TABLE_SIZE/ 2; // 한쪽 컬럼에 들어갈 개수
private final AmazonS3Manager amazonS3Manager;
private final MarkdownFileUploader markdownFileUploader;
private final FileConvertHelper fileConvertHelper;

@Override
@Transactional
Expand Down Expand Up @@ -246,10 +238,10 @@ private String createAndUploadListFileAndThumbnail(
// // 폰트 경로
// URL resource = getClass().getClassLoader().getResource("templates/NotoSansKR-Regular.ttf");
// File reg = new File(resource.toURI()); // catch에서 Exception 따로 처리해주기
listHtmlParticipant = injectPageMarginStyle(listHtmlParticipant);
listHtmlWinner = injectPageMarginStyle(listHtmlWinner);
byte[] shiftedPdfBytesParticipant = convertHtmlToPdf(listHtmlParticipant, fontBytes);
byte[] shiftedPdfBytesWinner = convertHtmlToPdf(listHtmlWinner, fontBytes);
listHtmlParticipant = fileConvertHelper.injectPageMarginStyle(listHtmlParticipant);
listHtmlWinner = fileConvertHelper.injectPageMarginStyle(listHtmlWinner);
byte[] shiftedPdfBytesParticipant = fileConvertHelper.convertHtmlToPdf(listHtmlParticipant, fontBytes);
byte[] shiftedPdfBytesWinner = fileConvertHelper.convertHtmlToPdf(listHtmlWinner, fontBytes);
pdfBytesParticipant = addPdfTitle(shiftedPdfBytesParticipant, request.getTitle(), fontBytes);
pdfBytesWinner = addPdfTitle(shiftedPdfBytesWinner, request.getTitle(), fontBytes);
docxBytesParticipant = createWord(ListType.PARTICIPANT, request.getTitle(), snsEvent);
Expand Down Expand Up @@ -517,47 +509,6 @@ private String injectHead(String html) {
}
}

private String injectPageMarginStyle(String html) {
String styleBlock = """
<style>
@page {
size: A4;
margin-top: 80pt;
margin-bottom: 80pt;
}
@page :first {
margin-top: 90pt; /* 첫 페이지만 위 여백 크게 */
}
</style>
""";
String lowerHtml = html.toLowerCase();
if (lowerHtml.contains("<head>")) {
// <head> 태그가 있는 경우 → 바로 뒤에 스타일 삽입
return html.replaceFirst("(?i)<head>", "<head>" + styleBlock);
} else {
// <head> 태그가 없는 경우 → <html> 다음에 <head> 생성 후 스타일 삽입
return html.replaceFirst("(?i)<html>", "<html><head>" + styleBlock + "</head>");
}
}

private byte[] convertHtmlToPdf(String listHtml, byte[] fontBytes) throws Exception {
// Openhtmltopdf/Flying Saucer를 사용하여 PDF 변환
ByteArrayOutputStream baos = new ByteArrayOutputStream();
PdfRendererBuilder builder = new PdfRendererBuilder();
builder.useFastMode();
builder.withHtmlContent(listHtml, null); // ex) "file:/opt/app/static/" or "https://your.cdn/", // base url 설정, 직접css파일 가져오거나 프론트엔드 배포 후 적용
builder.toStream(baos);
// 한글 폰트 임베딩
// byte[] → 임시 파일
if (fontBytes != null && fontBytes.length > 0) {
Path tmpFont = Files.createTempFile("NotoSansKR-", ".ttf");
Files.write(tmpFont, fontBytes, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING);
builder.useFont(tmpFont.toFile(), "NotoSansKR");
}
builder.run();
return baos.toByteArray();
}

private byte[] addPdfTitle(byte[] pdfBytes, String text, byte[] fontBytes) throws Exception {
ByteArrayOutputStream out = new ByteArrayOutputStream();
PdfReader reader = new PdfReader(new ByteArrayInputStream(pdfBytes));
Expand Down
Loading