From ba225cc7f1d9dcf183265ee25a7561a79289df14 Mon Sep 17 00:00:00 2001 From: Jinho622 Date: Wed, 13 Aug 2025 10:25:55 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat/#194:=20SNS=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B0=B8=EC=97=AC=EC=9E=90,=20=EB=8B=B9=EC=B2=A8?= =?UTF-8?q?=EC=9E=90=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=8C=8C=EC=9D=BC(PD?= =?UTF-8?q?F,=20Word)=EC=9D=80=20=EB=B0=94=EC=9D=B4=ED=8A=B8=20=EB=B0=B0?= =?UTF-8?q?=EC=97=B4=EB=A1=9C=20=EC=8D=B8=EB=84=A4=EC=9D=BC=EC=9D=80=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=EB=A1=9C=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=ED=9B=84=20S3=EC=97=90=20=EC=97=85=EB=A1=9C=EB=93=9C=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 참여자, 당첨자 리스트 파일과 썸네일 이미지 S3에 업로드 후 각각의 keyName을 SnsEvent Entity에 저장 - PDF 파일: Thymeleaf 템플릿에 데이터를 바인딩해 HTML로 렌더링 후 PDF로 변환 - Word 파일: 데이터가 포함된 표(Table)를 생성해 저장 - 썸네일은 생성된 파일을 기반으로 이미지 변환 후 업로드 --- build.gradle | 3 + .../api/domain/snsEvent/entity/SnsEvent.java | 30 +++++ .../service/SnsEventCommandServiceImpl.java | 120 +++++++++++++++++- .../api/infra/s3/MarkdownFileUploader.java | 24 ++++ .../sns-event-list-pdf-template.html | 79 ++++++++++++ 5 files changed, 249 insertions(+), 7 deletions(-) create mode 100644 src/main/resources/templates/sns-event-list-pdf-template.html diff --git a/build.gradle b/build.gradle index 99376e16..b6ca1fc7 100644 --- a/build.gradle +++ b/build.gradle @@ -127,6 +127,9 @@ dependencies { // Apache POI의 XWPF(XML Word Processing Format) implementation 'org.apache.poi:poi-ooxml:5.2.5' + + //thymeleaf + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' } dependencyManagement { diff --git a/src/main/java/com/haru/api/domain/snsEvent/entity/SnsEvent.java b/src/main/java/com/haru/api/domain/snsEvent/entity/SnsEvent.java index da9775c1..b0d79fe8 100644 --- a/src/main/java/com/haru/api/domain/snsEvent/entity/SnsEvent.java +++ b/src/main/java/com/haru/api/domain/snsEvent/entity/SnsEvent.java @@ -42,6 +42,21 @@ public class SnsEvent extends BaseEntity { @JoinColumn(name = "workspace_id") private Workspace workspace; + @Column(columnDefinition = "TEXT") + private String keyNameParticipantPdf; + + @Column(columnDefinition = "TEXT") + private String keyNameParticipantWord; + + @Column(columnDefinition = "TEXT") + private String keyNameWinnerPdf; + + @Column(columnDefinition = "TEXT") + private String keyNameWinnerWord; + + @Column(columnDefinition = "TEXT") + private String thumbnailKey; + @OneToMany(mappedBy = "snsEvent", cascade = CascadeType.ALL, fetch = FetchType.LAZY) private List participantList = new ArrayList<>(); @@ -59,4 +74,19 @@ public void setWorkspace(Workspace workspace) { public void updateTitle(String title) { this.title = title; } + + public void updateKeyNameParticipantPdf( + String keyNameParicipantPdf, + String keyNameParicipantWord, + String keyNameWinnerPdf, + String keyNameWinnerWord) { + this.keyNameParticipantPdf = keyNameParicipantPdf; + this.keyNameParticipantWord = keyNameParicipantWord; + this.keyNameWinnerPdf = keyNameWinnerPdf; + this.keyNameWinnerWord = keyNameWinnerWord; + } + + public void updateThumbnailKey(String thumbnailKey) { + this.thumbnailKey = thumbnailKey; + } } 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 76c8399a..f847c46e 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 @@ -29,6 +29,8 @@ import com.haru.api.global.apiPayload.exception.handler.UserDocumentLastOpenedHandler; import com.haru.api.global.apiPayload.exception.handler.WorkspaceHandler; 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; @@ -42,6 +44,8 @@ import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; import java.io.*; import java.net.URL; @@ -56,6 +60,7 @@ @RequiredArgsConstructor public class SnsEventCommandServiceImpl implements SnsEventCommandService{ + private final SpringTemplateEngine templateEngine; @Value("${instagram.client.id}") private String instagramClientId; @Value("${instagram.client.secret}") @@ -73,8 +78,11 @@ public class SnsEventCommandServiceImpl implements SnsEventCommandService{ private final InstagramOauth2RestTemplate instagramOauth2RestTemplate; private final int WORD_TABLE_SIZE = 40; // 페이지당 총 아이디 수 private final int PER_COL = WORD_TABLE_SIZE/ 2; // 한쪽 컬럼에 들어갈 개수 + private final AmazonS3Manager amazonS3Manager; + private final MarkdownFileUploader markdownFileUploader; @Override + @Transactional public SnsEventResponseDTO.CreateSnsEventResponse createSnsEvent( Long workspaceId, SnsEventRequestDTO.CreateSnsRequest request @@ -168,11 +176,108 @@ public SnsEventResponseDTO.CreateSnsEventResponse createSnsEvent( winnerList.add(winner); } winnerRepository.saveAll(winnerList); + + // PDF, DOCX파일 바이트 배열로 생성 및 썸네일 생성 & 업로드 / DB에 keyName저장 + createAndUploadListFileAndThumbnail( + request, + savedSnsEvent + ); + return SnsEventResponseDTO.CreateSnsEventResponse.builder() .snsEventId(createdSnsEvent.getId()) .build(); } + private String createListHtml( + SnsEvent snsEvent, + ListType listType + ) { + if (listType == ListType.PARTICIPANT) { + List participantList = participantRepository.findAllBySnsEvent(snsEvent); + List leftList = new ArrayList<>(); + List rightList = new ArrayList<>(); + int total = participantList.size(); + int mid = (total + 1) / 2; + leftList = participantList.subList(0, mid); + rightList = participantList.subList(mid, total); + // Thymeleaf context에 데이터 세팅 + Context context = new Context(); + context.setVariable("leftList", leftList); + context.setVariable("rightList", rightList); + // 템플릿 렌더링 → HTML 문자열 생성 + return templateEngine.process("sns-event-list-pdf-template", context); + } else if (listType == ListType.WINNER) { + List winnerList = winnerRepository.findAllBySnsEvent(snsEvent); + List leftList = new ArrayList<>(); + List rightList = new ArrayList<>(); + int total = winnerList.size(); + int mid = (total + 1) / 2; + leftList = winnerList.subList(0, mid); + rightList = winnerList.subList(mid, total); + // Thymeleaf context에 데이터 세팅 + Context context = new Context(); + context.setVariable("leftList", leftList); + context.setVariable("rightList", rightList); + // 템플릿 렌더링 → HTML 문자열 생성 + return templateEngine.process("sns-event-list-pdf-template", context); + } else { + throw new SnsEventHandler(SNS_EVENT_WRONG_FORMAT); + } + + } + + private void createAndUploadListFileAndThumbnail( + SnsEventRequestDTO.CreateSnsRequest request, + SnsEvent snsEvent + ){ + String listHtmlParticipant = createListHtml(snsEvent, ListType.PARTICIPANT); + String listHtmlWinner = createListHtml(snsEvent, ListType.WINNER); + byte[] pdfBytesParticipant; + byte[] pdfBytesWinner; + byte[] docxBytesParticipant; + byte[] docxBytesWinner; + try { + // 폰트 경로 + 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, reg); + byte[] shiftedPdfBytesWinner = convertHtmlToPdf(listHtmlWinner, reg); + pdfBytesParticipant = addPdfTitle(shiftedPdfBytesParticipant, request.getTitle(), reg.getAbsolutePath()); + pdfBytesWinner = addPdfTitle(shiftedPdfBytesWinner, request.getTitle(), reg.getAbsolutePath()); + docxBytesParticipant = createWord(ListType.PARTICIPANT, request.getTitle(), snsEvent); + docxBytesWinner = createWord(ListType.WINNER, request.getTitle(), snsEvent ); + } catch (Exception e) { + log.error("Error creating document: {}", e.getMessage()); + throw new SnsEventHandler(SNS_EVENT_DOWNLOAD_LIST_ERROR); + } + // PDF, DOCS파일, 썸네일 S3에 업로드 및 DB에 keyName저장 + String fullPath = "sns-event/" + snsEvent.getId(); + String keyNameParicipantPdf = amazonS3Manager.generateKeyName(fullPath) + "." + "pdf"; + String keyNameParicipantWord = amazonS3Manager.generateKeyName(fullPath) + "." + "docx"; + String keyNameWinnerPdf = amazonS3Manager.generateKeyName(fullPath) + "." + "pdf"; + String keyNameWinnerWord = amazonS3Manager.generateKeyName(fullPath) + "." + "docx"; + amazonS3Manager.uploadFile(keyNameParicipantPdf, pdfBytesParticipant, "application/pdf"); + amazonS3Manager.uploadFile(keyNameWinnerPdf, pdfBytesWinner, "application/pdf"); + amazonS3Manager.uploadFile(keyNameParicipantWord, docxBytesParticipant, "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + amazonS3Manager.uploadFile(keyNameWinnerWord, docxBytesWinner, "application/vnd.openxmlformats-officedocument.wordprocessingml.document"); + // SNS 이벤트에 keyName 저장 + snsEvent.updateKeyNameParticipantPdf( + keyNameParicipantPdf, + keyNameParicipantWord, + keyNameWinnerPdf, + keyNameWinnerWord + ); + // SNS 이벤트 당첨자 PDF의 첫페이지 썸네일로 저장 + String thumbnailKey = markdownFileUploader.createOrUpdateThumbnailForSnsEvent( + pdfBytesWinner, + "sns-event", + null + ); + snsEvent.updateThumbnailKey(thumbnailKey); + } + private SnsEventResponseDTO.InstagramMediaResponse fetchInstagramMedia( String accessToken ) { @@ -368,7 +473,8 @@ public byte[] downloadList( return addPdfTitle(shiftedPdfByte, pdfTitle, reg.getAbsolutePath()); } else if (format == Format.DOCX) { - return createWord(listType, pdfTitle); + return createWord(listType, pdfTitle, snsEventRepository.findById(snsEventId) + .orElseThrow(() -> new SnsEventHandler(SNS_EVENT_NOT_FOUND))); } else throw new SnsEventHandler(SNS_EVENT_WRONG_FORMAT); } catch (Exception e) { @@ -403,7 +509,7 @@ private String injectPageMarginStyle(String html) { margin-bottom: 80pt; } @page :first { - margin-top: 100pt; /* 첫 페이지만 위 여백 크게 */ + margin-top: 90pt; /* 첫 페이지만 위 여백 크게 */ } """; @@ -447,8 +553,8 @@ private byte[] addPdfTitle(byte[] pdfBytes, String text, String fontPath) throws over.setFontAndSize(bf, 28f); // 글씨 크게 (28pt) // 페이지 폭 중앙 계산 float x = reader.getPageSize(i).getWidth() / 2; - // 페이지 상단에서 약간 내려오게 (40pt 여백) - float y = reader.getPageSize(i).getTop() - 60f; + // 페이지 상단에서 약간 내려오게 (70pt 여백) + float y = reader.getPageSize(i).getTop() - 70f; over.showTextAligned(Element.ALIGN_CENTER, text, x, y, 0); over.endText(); } @@ -457,16 +563,16 @@ private byte[] addPdfTitle(byte[] pdfBytes, String text, String fontPath) throws return out.toByteArray(); } - private byte[] createWord(ListType listType, String listTitle) throws Exception { // 참여자 또는 당첨자 리스트 DB에서 가져와 표로 만들어 word로 변환해서 응답주기 + private byte[] createWord(ListType listType, String listTitle, SnsEvent snsEvent) throws Exception { // 참여자 또는 당첨자 리스트 DB에서 가져와 표로 만들어 word로 변환해서 응답주기 List list = new ArrayList<>(); if (listType == ListType.PARTICIPANT) { - List participantList = participantRepository.findAll(); + List participantList = participantRepository.findAllBySnsEvent(snsEvent); for (Participant participant : participantList) { list.add(participant.getNickname()); } return createTable(list, listTitle); } else { - List winnerList = winnerRepository.findAll(); + List winnerList = winnerRepository.findAllBySnsEvent(snsEvent); for (Winner winner : winnerList) { list.add(winner.getNickname()); } diff --git a/src/main/java/com/haru/api/infra/s3/MarkdownFileUploader.java b/src/main/java/com/haru/api/infra/s3/MarkdownFileUploader.java index 263dc376..c5a081b2 100644 --- a/src/main/java/com/haru/api/infra/s3/MarkdownFileUploader.java +++ b/src/main/java/com/haru/api/infra/s3/MarkdownFileUploader.java @@ -83,4 +83,28 @@ public String createOrUpdateThumbnail(String pdfKey, String featurePath, String // 5. 사용된 썸네일의 key를 반환 return thumbnailKeyToUse; } + + public String createOrUpdateThumbnailForSnsEvent(byte[] pdfBytes, String featurePath, String existingThumbnailKey) { + // 1. 다운로드한 PDF로 새로운 썸네일 데이터 생성 + byte[] newThumbnailBytes = thumbnailGeneratorService.generate(pdfBytes); + + // 2. 사용할 썸네일 키 결정 + String thumbnailKeyToUse; + if (existingThumbnailKey != null && !existingThumbnailKey.isBlank()) { + // 기존 키가 제공되면, 그 키를 그대로 사용하여 갱신(덮어쓰기) + thumbnailKeyToUse = existingThumbnailKey; + log.info("기존 썸네일 갱신을 시작합니다. Key: {}", thumbnailKeyToUse); + } else { + // 기존 키가 없으면, 새로운 키를 생성 + thumbnailKeyToUse = amazonS3Manager.generateKeyName("thumbnails/" + featurePath) + "." + IMAGE_FORMAT; + log.info("새로운 썸네일 생성을 시작합니다. New Key: {}", thumbnailKeyToUse); + } + + // 3. 결정된 키로 썸네일을 S3에 업로드 + amazonS3Manager.uploadFile(thumbnailKeyToUse, newThumbnailBytes, "image/" + IMAGE_FORMAT); + log.info("썸네일 업로드/갱신 성공. Key: {}", thumbnailKeyToUse); + + // 4. 사용된 썸네일의 key를 반환 + return thumbnailKeyToUse; + } } diff --git a/src/main/resources/templates/sns-event-list-pdf-template.html b/src/main/resources/templates/sns-event-list-pdf-template.html new file mode 100644 index 00000000..44f3b4ef --- /dev/null +++ b/src/main/resources/templates/sns-event-list-pdf-template.html @@ -0,0 +1,79 @@ + + + + + SNS 이벤트 PDF 리스트 HTML 템플릿 + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
번호ID번호ID
+ + From 389b58368169bee8a680a92aaa712970445b6420 Mon Sep 17 00:00:00 2001 From: Jinho622 Date: Wed, 13 Aug 2025 11:55:55 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat/#194:=20=EA=B8=B0=EC=A1=B4=EC=97=90=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=EC=9D=84=20?= =?UTF-8?q?=EB=B0=94=EC=9D=B4=ED=8A=B8=20=EB=B0=B0=EC=97=B4=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=ED=95=98=EB=8D=98=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=EC=9D=98=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=9D=84=20S3=EC=97=90=20=EC=A0=80=EC=9E=A5=EB=90=9C?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=EC=9D=98=20=EB=8B=A4=EC=9A=B4=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20URL=EC=9D=84=20=EB=B0=98=ED=99=98=ED=95=98=EB=8A=94?= =?UTF-8?q?=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DB에 저장된 keyName으로 PresignedUrl을 만들어 10분까지 유효한 다운로드 링크인 PresignedUrl을 프론트엔드에 전달 --- .../controller/SnsEventController.java | 38 ++++++------ .../snsEvent/dto/SnsEventRequestDTO.java | 8 --- .../snsEvent/dto/SnsEventResponseDTO.java | 8 +++ .../service/SnsEventCommandService.java | 2 +- .../service/SnsEventCommandServiceImpl.java | 61 ++++++++++++------- .../apiPayload/code/status/ErrorStatus.java | 2 + .../haru/api/infra/s3/AmazonS3Manager.java | 19 ++++++ 7 files changed, 89 insertions(+), 49 deletions(-) diff --git a/src/main/java/com/haru/api/domain/snsEvent/controller/SnsEventController.java b/src/main/java/com/haru/api/domain/snsEvent/controller/SnsEventController.java index d0dfd92c..6cf6f77f 100644 --- a/src/main/java/com/haru/api/domain/snsEvent/controller/SnsEventController.java +++ b/src/main/java/com/haru/api/domain/snsEvent/controller/SnsEventController.java @@ -131,30 +131,30 @@ public ApiResponse getSnsEvent( " 참여자 및 당첨자 리스트 다운로드 API입니다. Header에 access token을 넣고 Path Variable에는 snsEventId를 넣어 요청해주세요. Query String에는 다운로드 형식을 넣어주시고 다운로드 형식이 docx라면 리스트의 HTML을 Request Body에 넣어주세요." ) @PostMapping("/{snsEventId}/list/download") - public ResponseEntity downloadList( + public ApiResponse downloadList( @PathVariable String snsEventId, @RequestParam ListType listType, - @RequestParam Format format, - @RequestBody SnsEventRequestDTO.DownloadListRequest request + @RequestParam Format format ) { Long userId = SecurityUtil.getCurrentUserId(); - byte[] fileBytes = snsEventCommandService.downloadList( - userId, - Long.parseLong(snsEventId), - listType, - format, - request + return ApiResponse.onSuccess( + snsEventCommandService.downloadList( + userId, + Long.parseLong(snsEventId), + listType, + format + ) ); - String listTypefileName = listType == ListType.PARTICIPANT ? "참여자" : "당첨자"; - String filename = format == Format.PDF ? listTypefileName + ".pdf" : listTypefileName + ".docx"; - String contentType = format == Format.PDF - ? MediaType.APPLICATION_PDF_VALUE - : "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; - - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") - .contentType(MediaType.parseMediaType(contentType)) - .body(fileBytes); +// String listTypefileName = listType == ListType.PARTICIPANT ? "참여자" : "당첨자"; +// String filename = format == Format.PDF ? listTypefileName + ".pdf" : listTypefileName + ".docx"; +// String contentType = format == Format.PDF +// ? MediaType.APPLICATION_PDF_VALUE +// : "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; +// +// return ResponseEntity.ok() +// .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") +// .contentType(MediaType.parseMediaType(contentType)) +// .body(fileBytes); } } diff --git a/src/main/java/com/haru/api/domain/snsEvent/dto/SnsEventRequestDTO.java b/src/main/java/com/haru/api/domain/snsEvent/dto/SnsEventRequestDTO.java index b6388fe3..127bced6 100644 --- a/src/main/java/com/haru/api/domain/snsEvent/dto/SnsEventRequestDTO.java +++ b/src/main/java/com/haru/api/domain/snsEvent/dto/SnsEventRequestDTO.java @@ -36,12 +36,4 @@ public static class SnsCondition { public static class UpdateSnsEventRequest { private String title; } - - @Getter - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class DownloadListRequest { - private String listHtml; - } } diff --git a/src/main/java/com/haru/api/domain/snsEvent/dto/SnsEventResponseDTO.java b/src/main/java/com/haru/api/domain/snsEvent/dto/SnsEventResponseDTO.java index 72e68d58..3640bc43 100644 --- a/src/main/java/com/haru/api/domain/snsEvent/dto/SnsEventResponseDTO.java +++ b/src/main/java/com/haru/api/domain/snsEvent/dto/SnsEventResponseDTO.java @@ -109,4 +109,12 @@ public static class ParticipantResponse { public static class WinnerResponse { private String account; } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ListDownLoadLinkResponse { + private String downloadLink; + } } diff --git a/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventCommandService.java b/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventCommandService.java index c14a9976..c7ab30ad 100644 --- a/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventCommandService.java +++ b/src/main/java/com/haru/api/domain/snsEvent/service/SnsEventCommandService.java @@ -14,5 +14,5 @@ public interface SnsEventCommandService { void deleteSnsEvent(Long userId, Long snsEventId); - byte[] downloadList(Long userId, Long snsEventId, ListType listType, Format format, SnsEventRequestDTO.DownloadListRequest request); + SnsEventResponseDTO.ListDownLoadLinkResponse downloadList(Long userId, Long snsEventId, ListType listType, Format format); } 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 f847c46e..26109f33 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 @@ -450,37 +450,56 @@ public void deleteSnsEvent( } @Override - public byte[] downloadList( + public SnsEventResponseDTO.ListDownLoadLinkResponse downloadList( Long userId, Long snsEventId, ListType listType, - Format format, - SnsEventRequestDTO.DownloadListRequest request + Format format ) { + String downloadLink = ""; User foundUser = userRepository.findById(userId) .orElseThrow(() -> new MemberHandler(MEMBER_NOT_FOUND)); - String pdfTitle = snsEventRepository.findById(snsEventId) - .orElseThrow(() -> new SnsEventHandler(SNS_EVENT_NOT_FOUND)) - .getTitle(); - try { + SnsEvent foundSnsEvent = snsEventRepository.findById(snsEventId) + .orElseThrow(() -> new SnsEventHandler(SNS_EVENT_NOT_FOUND)); + String snsEventTitle = foundSnsEvent.getTitle(); + if (listType == ListType.PARTICIPANT) { if (format == Format.PDF) { - // 폰트 경로 - URL resource = getClass().getClassLoader().getResource("templates/NotoSansKR-Regular.ttf"); - File reg = new File(resource.toURI()); // catch에서 Exception 따로 처리해주기 - String listHtml = injectHead(request.getListHtml()); - listHtml = injectPageMarginStyle(listHtml); - byte[] shiftedPdfByte = convertHtmlToPdf(listHtml, reg); - return addPdfTitle(shiftedPdfByte, pdfTitle, reg.getAbsolutePath()); + String keyName = foundSnsEvent.getKeyNameParticipantPdf(); + if (keyName == null || keyName.isEmpty()) { + throw new SnsEventHandler(SNS_EVENT_LIST_KEYNAME_NOT_FOUND); + } + downloadLink = amazonS3Manager.generatePresignedUrlForDownloadPdfAndWord(keyName, snsEventTitle + "_participnat_list.pdf"); + } else if (format == Format.DOCX) { + String keyName = foundSnsEvent.getKeyNameParticipantWord(); + if (keyName == null || keyName.isEmpty()) { + throw new SnsEventHandler(SNS_EVENT_LIST_KEYNAME_NOT_FOUND); + } + downloadLink = amazonS3Manager.generatePresignedUrlForDownloadPdfAndWord(keyName, snsEventTitle + "_participnat_list.docx"); + } else { + throw new SnsEventHandler(SNS_EVENT_WRONG_FORMAT); } - else if (format == Format.DOCX) { - return createWord(listType, pdfTitle, snsEventRepository.findById(snsEventId) - .orElseThrow(() -> new SnsEventHandler(SNS_EVENT_NOT_FOUND))); + } else if (listType == ListType.WINNER) { + if (format == Format.PDF) { + String keyName = foundSnsEvent.getKeyNameWinnerPdf(); + if (keyName == null || keyName.isEmpty()) { + throw new SnsEventHandler(SNS_EVENT_LIST_KEYNAME_NOT_FOUND); + } + downloadLink = amazonS3Manager.generatePresignedUrlForDownloadPdfAndWord(keyName, snsEventTitle + "_winner_list.pdf"); + } else if (format == Format.DOCX) { + String keyName = foundSnsEvent.getKeyNameWinnerWord(); + if (keyName == null || keyName.isEmpty()) { + throw new SnsEventHandler(SNS_EVENT_LIST_KEYNAME_NOT_FOUND); + } + downloadLink = amazonS3Manager.generatePresignedUrlForDownloadPdfAndWord(keyName, snsEventTitle + "_winner_list.docx"); + } else { + throw new SnsEventHandler(SNS_EVENT_WRONG_FORMAT); } - else throw new SnsEventHandler(SNS_EVENT_WRONG_FORMAT); - } catch (Exception e) { - log.error("Error creating document: {}", e.getMessage()); - throw new SnsEventHandler(SNS_EVENT_DOWNLOAD_LIST_ERROR); + } else { + throw new SnsEventHandler(SNS_EVENT_WRONG_LIST_TYPE); } + return SnsEventResponseDTO.ListDownLoadLinkResponse.builder() + .downloadLink(downloadLink) + .build(); } private String injectHead(String html) { diff --git a/src/main/java/com/haru/api/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/haru/api/global/apiPayload/code/status/ErrorStatus.java index eac22e38..1d011876 100644 --- a/src/main/java/com/haru/api/global/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/com/haru/api/global/apiPayload/code/status/ErrorStatus.java @@ -72,6 +72,8 @@ public enum ErrorStatus implements BaseErrorCode { SNS_EVENT_INSTAGRAM_ALREADY_LINKED(HttpStatus.BAD_REQUEST, "SNS_EVENT4008", "이미 연동된 인스타그램 계정입니다."), SNS_EVENT_WRONG_FORMAT(HttpStatus.BAD_REQUEST, "SNS_EVENT4009", "잘못된 다운로드 파일 형식입니다."), SNS_EVENT_DOWNLOAD_LIST_ERROR(HttpStatus.BAD_REQUEST, "SNS_EVENT4010", "리스트 다운로드중 오류가 발생했습니다."), + SNS_EVENT_LIST_KEYNAME_NOT_FOUND(HttpStatus.BAD_REQUEST, "SNS_EVENT4011", "리스트 다운로드중 키 이름이 존재하지 않습니다."), + SNS_EVENT_WRONG_LIST_TYPE(HttpStatus.BAD_REQUEST, "SNS_EVENT4012", "리스트 다운로드중 잘못된 리스트 타입(참여자, 당첨자)입니다."), // last opened 관련 에러 USER_DOCUMENT_LAST_OPENED_NOT_FOUND(HttpStatus.NOT_FOUND, "LASTOPENED4001", "해당 문서에 대한 마지막 조회 데이터가 존재하지 않습니다."), diff --git a/src/main/java/com/haru/api/infra/s3/AmazonS3Manager.java b/src/main/java/com/haru/api/infra/s3/AmazonS3Manager.java index 29719015..777268aa 100644 --- a/src/main/java/com/haru/api/infra/s3/AmazonS3Manager.java +++ b/src/main/java/com/haru/api/infra/s3/AmazonS3Manager.java @@ -85,6 +85,25 @@ public String generatePresignedUrl(String keyName) { return presignedRequest.url().toString(); } + // 프론트로 수정한 파일명으로 다운로드 가능한 url을 보내기 위해 사용하는 메서드 + public String generatePresignedUrlForDownloadPdfAndWord(String keyName, String fileName) { + if (keyName == null || keyName.isBlank()) return null; + + GetObjectRequest getObjectRequest = GetObjectRequest.builder() + .bucket(amazonConfig.getBucket()) + .key(keyName) + .responseContentDisposition("attachment; filename=\"" + fileName + "\"") // S3파일(PDF) 링크 클릭 시 즉시 다운가능,파일 다운로드 시 이름 설정 + .build(); + + GetObjectPresignRequest presignRequest = GetObjectPresignRequest.builder() + .signatureDuration(Duration.ofMinutes(10)) + .getObjectRequest(getObjectRequest) + .build(); + + PresignedGetObjectRequest presignedRequest = s3Presigner.presignGetObject(presignRequest); + return presignedRequest.url().toString(); + } + //S3에서 key에 해당하는 파일을 다운로드하여 byte 배열로 반환 -> 썸네일 생성시 활용 public byte[] downloadFile(String keyName) { if (keyName == null || keyName.isBlank()) { From cac5dbb2383a450e1dd9dcb39e12df5e96160235 Mon Sep 17 00:00:00 2001 From: Jinho622 Date: Wed, 13 Aug 2025 20:06:31 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat/#194:=20UserDocumentLastOpened=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=EC=97=90=20thumbnailKeyName?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20SNS=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=83=9D=EC=84=B1=20=EC=8B=9C=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EB=90=9C=20thubnailKeyName=EC=9D=B4=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EB=8F=BC=EC=9E=88=EB=8A=94=20UserDocumentLastOpened=EC=97=90?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=EB=90=98=EB=8F=84=EB=A1=9D=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lastOpened/entity/UserDocumentLastOpened.java | 7 +++++++ .../service/SnsEventCommandServiceImpl.java | 14 ++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/haru/api/domain/lastOpened/entity/UserDocumentLastOpened.java b/src/main/java/com/haru/api/domain/lastOpened/entity/UserDocumentLastOpened.java index ac93d08c..280549f1 100644 --- a/src/main/java/com/haru/api/domain/lastOpened/entity/UserDocumentLastOpened.java +++ b/src/main/java/com/haru/api/domain/lastOpened/entity/UserDocumentLastOpened.java @@ -40,6 +40,9 @@ public class UserDocumentLastOpened { @Column(name = "last_opened") private LocalDateTime lastOpened; + @Column(columnDefinition = "TEXT") + private String thumbnailKeyName; + public void updateLastOpened(LocalDateTime lastOpened) { this.lastOpened = lastOpened; } @@ -48,4 +51,8 @@ public void updateTitle(String title) { this.title = title; } + public void updateThumbnailKeyName(String thumbnailKeyName) { + this.thumbnailKeyName = thumbnailKeyName; + } + } 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 26109f33..13cb107d 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 @@ -101,10 +101,8 @@ public SnsEventResponseDTO.CreateSnsEventResponse createSnsEvent( // mood tracker 생성 시 last opened에 추가 // 마지막으로 연 시간은 null - UserDocumentId documentId = new UserDocumentId(foundUser.getId(), savedSnsEvent.getId(), DocumentType.SNS_EVENT_ASSISTANT); - - userDocumentLastOpenedRepository.save( + UserDocumentLastOpened savedUserDocumentLastOpened = userDocumentLastOpenedRepository.save( UserDocumentLastOpened.builder() .id(documentId) .user(foundUser) @@ -180,7 +178,8 @@ public SnsEventResponseDTO.CreateSnsEventResponse createSnsEvent( // PDF, DOCX파일 바이트 배열로 생성 및 썸네일 생성 & 업로드 / DB에 keyName저장 createAndUploadListFileAndThumbnail( request, - savedSnsEvent + savedSnsEvent, + savedUserDocumentLastOpened ); return SnsEventResponseDTO.CreateSnsEventResponse.builder() @@ -228,7 +227,8 @@ private String createListHtml( private void createAndUploadListFileAndThumbnail( SnsEventRequestDTO.CreateSnsRequest request, - SnsEvent snsEvent + SnsEvent snsEvent, + UserDocumentLastOpened userDocumentLastOpened ){ String listHtmlParticipant = createListHtml(snsEvent, ListType.PARTICIPANT); String listHtmlWinner = createListHtml(snsEvent, ListType.WINNER); @@ -269,13 +269,15 @@ private void createAndUploadListFileAndThumbnail( keyNameWinnerPdf, keyNameWinnerWord ); - // SNS 이벤트 당첨자 PDF의 첫페이지 썸네일로 저장 + // SNS 이벤트 당첨자 PDF의 첫페이지 썸네일로 S3에 업로드 String thumbnailKey = markdownFileUploader.createOrUpdateThumbnailForSnsEvent( pdfBytesWinner, "sns-event", null ); snsEvent.updateThumbnailKey(thumbnailKey); + // UserDocumentLastOpened Entity에도 thmbnailKeyName추가 + userDocumentLastOpened.updateThumbnailKeyName(thumbnailKey); } private SnsEventResponseDTO.InstagramMediaResponse fetchInstagramMedia( From 2def47c65ebc0b855753863f1bd14f516b06886c Mon Sep 17 00:00:00 2001 From: Jinho622 Date: Wed, 13 Aug 2025 20:09:07 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix/#194:=20SnsEvent=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=EC=9D=98=20updateKeyNameParticipantPdf=20=EB=A9=94?= =?UTF-8?q?=EC=86=8C=EB=93=9C=EC=9D=98=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=20=EC=98=A4=ED=83=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/haru/api/domain/snsEvent/entity/SnsEvent.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/haru/api/domain/snsEvent/entity/SnsEvent.java b/src/main/java/com/haru/api/domain/snsEvent/entity/SnsEvent.java index b0d79fe8..ab816c2e 100644 --- a/src/main/java/com/haru/api/domain/snsEvent/entity/SnsEvent.java +++ b/src/main/java/com/haru/api/domain/snsEvent/entity/SnsEvent.java @@ -76,11 +76,11 @@ public void updateTitle(String title) { } public void updateKeyNameParticipantPdf( - String keyNameParicipantPdf, + String keyNameParticipantPdf, String keyNameParicipantWord, String keyNameWinnerPdf, String keyNameWinnerWord) { - this.keyNameParticipantPdf = keyNameParicipantPdf; + this.keyNameParticipantPdf = keyNameParticipantPdf; this.keyNameParticipantWord = keyNameParicipantWord; this.keyNameWinnerPdf = keyNameWinnerPdf; this.keyNameWinnerWord = keyNameWinnerWord;