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 redisTemplate; diff --git a/src/main/java/com/haru/api/global/util/file/FileConvertHelper.java b/src/main/java/com/haru/api/global/util/file/FileConvertHelper.java new file mode 100644 index 00000000..b064f427 --- /dev/null +++ b/src/main/java/com/haru/api/global/util/file/FileConvertHelper.java @@ -0,0 +1,146 @@ +package com.haru.api.global.util.file; + +import com.openhtmltopdf.pdfboxout.PdfRendererBuilder; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFRun; +import org.commonmark.node.Node; +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayOutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.time.format.DateTimeFormatter; + +@Component +public class FileConvertHelper { + + // dueDate 포맷터: LocalDateTime이면 yyyy-MM-dd HH:mm, 아니면 toString + private static final DateTimeFormatter DTF = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + public String formatDueDate(Object dueDate) { + if (dueDate == null) return null; + try { + if (dueDate instanceof java.time.LocalDateTime ldt) { + return ldt.format(DTF); + } + if (dueDate instanceof java.time.OffsetDateTime odt) { + return odt.toLocalDateTime().format(DTF); + } + if (dueDate instanceof java.time.ZonedDateTime zdt) { + return zdt.toLocalDateTime().format(DTF); + } + } catch (Exception ignored) { } + return dueDate.toString(); + } + + public void addHeading(XWPFDocument doc, String text, int fontSize, String fontName) { + XWPFParagraph p = doc.createParagraph(); + p.setSpacingBefore(100); + p.setSpacingAfter(100); + XWPFRun r = p.createRun(); + r.setText(text); + r.setBold(true); + r.setFontSize(fontSize); + r.setFontFamily(fontName); + } + + public void addDocsBullet(XWPFDocument doc, String text, String fontName) { + // 간단 불릿: 글머리 기호만 붙여 출력 + XWPFParagraph p = doc.createParagraph(); + p.setIndentationLeft(360); // 약 0.5인치 + XWPFRun r = p.createRun(); + r.setText("• " + text); + r.setFontSize(12); + r.setFontFamily(fontName); + } + + public void addParagraph(XWPFDocument doc, String text, int fontSize, boolean bold, String fontName) { + XWPFParagraph p = doc.createParagraph(); + XWPFRun r = p.createRun(); + r.setFontSize(fontSize); + r.setBold(bold); + // 문장 내 강제 개행(\n) 처리 + String[] parts = text.split("\n"); + for (int i = 0; i < parts.length; i++) { + if (i > 0) r.addBreak(); + r.setText(parts[i]); + r.setFontFamily(fontName); + } + } + + // 마크다운 → HTML (그대로 사용) + public String markdownToHtml(String markdownText) { + if (markdownText == null) return ""; + Parser parser = Parser.builder().build(); + HtmlRenderer renderer = HtmlRenderer.builder().build(); + Node doc = parser.parse(markdownText); + return renderer.render(doc); + } + + /** + * PDF용 제목 스타일 주입: .title-box 가운데 정렬, .title-text 폰트 크기 확대 + * 템플릿의 안에 + """; + String lower = html.toLowerCase(); + if (lower.contains("")) { + return html.replaceFirst("(?i)", "" + styleBlock); + } else { + return html.replaceFirst("(?i)", "" + styleBlock + ""); + } + } + + /** + * PDF용 제목 스타일 주입: 템플릿의 안에 + """; + String lowerHtml = html.toLowerCase(); + if (lowerHtml.contains("")) { + // 태그가 있는 경우 → 바로 뒤에 스타일 삽입 + return html.replaceFirst("(?i)", "" + styleBlock); + } else { + // 태그가 없는 경우 → 다음에 생성 후 스타일 삽입 + return html.replaceFirst("(?i)", "" + styleBlock + ""); + } + } + + public 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(); + } +} diff --git a/src/main/java/com/haru/api/infra/api/client/ChatGPTClient.java b/src/main/java/com/haru/api/infra/api/client/ChatGPTClient.java index 1373108a..1adc2945 100644 --- a/src/main/java/com/haru/api/infra/api/client/ChatGPTClient.java +++ b/src/main/java/com/haru/api/infra/api/client/ChatGPTClient.java @@ -144,16 +144,16 @@ public Mono getMoodTrackerReport(String userMessageContent StringBuilder sb = new StringBuilder(); sb.append("너는 팀 심리 및 조직 문화 분석가야. 아래의 설문 응답을 통해 전체 설문을 종합한 마크다운 형식의 분석 리포트를 작성하고, 설문 질문 별로 개선 제안을 각 1개씩 제시해줘.\n\n"); - sb.append("💡 최종 리포트 형식은 다음과 같아야 합니다:\n"); - sb.append("1. {title} + 리포트\n"); + sb.append("💡 최종 리포트 형식은 무조건 다음과 같은 마크다운 형식이어야만 합니다:\n"); + sb.append("### 1. {title} + 리포트\n"); sb.append(" - 대상과 목적, 분석 방식 등을 간단히 정리\n"); - sb.append("2. 주요 인사이트 요약 (AI가 뽑은 핵심 요약)\n"); + sb.append("### 2. 주요 인사이트 요약 (HaRu AI가 뽑은 핵심 요약)\n"); sb.append(" - 사용자의 응답 중 반복되거나 주목할 만한 인사이트를 요약\n"); sb.append(" - 전체 응답자의 몇 %가 어떤 패턴을 보였는지도 서술\n"); - sb.append("3. 자유 응답 기반 주요 키워드 정리 (많이 등장한 순서대로)\n"); + sb.append("### 3. 자유 응답 기반 주요 키워드 정리\n"); sb.append(" - 예: 잦힌 (37건), 말은 덜 불분명 (29건) 등\n\n"); - sb.append("응답은 반드시 응답은 JSON 문자열 형식으로 주고, 백틱이나 마크다운 없이 순수 JSON만 반환해줘. 다음 JSON 형식으로 해줘. 형식만 따르고, 값은 생성한 값을 넣어줘야해. 질문에 대한 제안은 입력받은 질문 Id와 해당 질문에 매칭되는 제안 내용을 넣어줘. : \n"); + sb.append("주요 인사이트 요약은 핵심을 요약해주고, 자유 응답 기반 주요 키워드 정리는 많이 등장한 순서대로 정렬해줘야해. 응답은 반드시 응답은 JSON 문자열 형식으로 주고, 백틱이나 마크다운 없이 순수 JSON만 반환해줘. 다음 JSON 형식으로 해줘. 형식만 따르고, 값은 생성한 값을 넣어줘야해. 질문에 대한 제안은 입력받은 질문 Id와 해당 질문에 매칭되는 제안 내용을 넣어줘. : \n"); sb.append("{\n"); sb.append(" \"report\": \"전체 리포트 마크다운 텍스트\",\n"); sb.append(" \"suggestionsByQuestionId\": {\n"); diff --git a/src/main/java/com/haru/api/infra/redis/RedisReportConsumer.java b/src/main/java/com/haru/api/infra/redis/RedisReportConsumer.java index 0f96e361..cb149aa1 100644 --- a/src/main/java/com/haru/api/infra/redis/RedisReportConsumer.java +++ b/src/main/java/com/haru/api/infra/redis/RedisReportConsumer.java @@ -4,6 +4,7 @@ import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -19,7 +20,8 @@ public class RedisReportConsumer { private final StringRedisTemplate redisTemplate; private final MoodTrackerReportService moodTrackerReportService; - private static final String QUEUE_KEY = "MOOD_TRACKER_REPORT_GPT_QUEUE"; + @Value("${queue-name}") + private String QUEUE_KEY; private static final long BATCH_SIZE = 20; @Transactional diff --git a/src/main/java/com/haru/api/infra/redis/RedisReportProducer.java b/src/main/java/com/haru/api/infra/redis/RedisReportProducer.java index c9babdc7..60ef04bf 100644 --- a/src/main/java/com/haru/api/infra/redis/RedisReportProducer.java +++ b/src/main/java/com/haru/api/infra/redis/RedisReportProducer.java @@ -1,6 +1,7 @@ package com.haru.api.infra.redis; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; @@ -12,7 +13,9 @@ public class RedisReportProducer { private final StringRedisTemplate redisTemplate; - private static final String QUEUE_KEY = "MOOD_TRACKER_REPORT_GPT_QUEUE"; + + @Value("${queue-name}") + private String QUEUE_KEY; public void scheduleReport(Long moodTrackerId, LocalDateTime dueDate) { long score = dueDate.atZone(ZoneId.systemDefault()).toEpochSecond(); diff --git a/src/main/resources/templates/mood-tracker-report-template.html b/src/main/resources/templates/mood-tracker-report-template.html index e69de29b..06ba39b9 100644 --- a/src/main/resources/templates/mood-tracker-report-template.html +++ b/src/main/resources/templates/mood-tracker-report-template.html @@ -0,0 +1,128 @@ + + + + + 팀 분위기 조사 + + + +
+
+ +

팀 분위기 조사

+ + +

+ 작성자: 이호근 + / + 마감일: 2025-07-29 01:30 +

+ +
+ + +
+ +
+
+
+ + diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index bd318086..5f8ed5ae 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -48,6 +48,8 @@ invite-url: invite-url survey-url: survey-url +queue-name: queue-name + google-login-frontend-url: google-login-frontend-url instagram: