diff --git a/build.gradle b/build.gradle index 63ee0830..647ac4b1 100644 --- a/build.gradle +++ b/build.gradle @@ -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 @@ -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' @@ -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' diff --git a/src/main/java/com/haru/api/domain/moodTracker/service/MoodTrackerReportServiceImpl.java b/src/main/java/com/haru/api/domain/moodTracker/service/MoodTrackerReportServiceImpl.java index 5d7c0014..103b7f10 100644 --- a/src/main/java/com/haru/api/domain/moodTracker/service/MoodTrackerReportServiceImpl.java +++ b/src/main/java/com/haru/api/domain/moodTracker/service/MoodTrackerReportServiceImpl.java @@ -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.*; @@ -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; @@ -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) { @@ -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", @@ -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(); + } } } diff --git a/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventCommandServiceImpl.java b/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventCommandServiceImpl.java index f222028f..5b08770e 100644 --- a/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventCommandServiceImpl.java +++ b/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventCommandServiceImpl.java @@ -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; @@ -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; @@ -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.*; @@ -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 @@ -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); @@ -517,47 +509,6 @@ private String injectHead(String html) { } } - private String injectPageMarginStyle(String html) { - String styleBlock = """ - - """; - String lowerHtml = html.toLowerCase(); - if (lowerHtml.contains("
")) { - // 태그가 있는 경우 → 바로 뒤에 스타일 삽입 - return html.replaceFirst("(?i)", "" + styleBlock); - } else { - // 태그가 없는 경우 → 다음에 생성 후 스타일 삽입 - return html.replaceFirst("(?i)", "" + styleBlock + ""); - } - } - - 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)); diff --git a/src/main/java/com/haru/api/domain/user/security/jwt/JwtAuthenticationFilter.java b/src/main/java/com/haru/api/domain/user/security/jwt/JwtAuthenticationFilter.java index b214cb73..0e14d8eb 100644 --- a/src/main/java/com/haru/api/domain/user/security/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/haru/api/domain/user/security/jwt/JwtAuthenticationFilter.java @@ -41,8 +41,9 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { "/api/v1/terms", "/api/v1/sns/oauth/callback", "/api/v1/users/signup/same", - "/favicon.ico" - + "/favicon.ico", + "/api/v1//mood-trackers/*/questions", + "/api/v1//mood-trackers/*/responses" }; private final JwtUtils jwtUtils; private final RedisTemplate