diff --git a/.gitignore b/.gitignore index 07fc33f..dea977d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ build/ !**/src/test/**/build/ /src/main/resources/firebase/ /src/main/resources/firebase/suppin-a5657-firebase-adminsdk-s75m9-d65cc88029.json +/src/main/resources/drivers/ + ### STS ### .apt_generated @@ -37,3 +39,5 @@ out/ ### VS Code ### .vscode/ + +app.log diff --git a/.platform/nginx.conf b/.platform/nginx.conf index 471a08e..53d9cc2 100644 --- a/.platform/nginx.conf +++ b/.platform/nginx.conf @@ -11,53 +11,57 @@ events { } http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - include conf.d/*.conf; - - map $http_upgrade $connection_upgrade { - default "upgrade"; - } - - upstream springboot { - server 127.0.0.1:8080; - keepalive 1024; - } - - server { - listen 80 default_server; - listen [::]:80 default_server; - - location / { - proxy_pass http://springboot; - # CORS 관련 헤더 추가 - add_header 'Access-Control-Allow-Origin' '*'; - add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS'; - add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type'; - proxy_http_version 1.1; - proxy_set_header Connection $connection_upgrade; - proxy_set_header Upgrade $http_upgrade; - - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - } - - access_log /var/log/nginx/access.log main; - - client_header_timeout 60; - client_body_timeout 60; - keepalive_timeout 60; - gzip off; - gzip_comp_level 4; - - # Include the Elastic Beanstalk generated locations - include conf.d/elasticbeanstalk/healthd.conf; - } + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + include conf.d/*.conf; + + map $http_upgrade $connection_upgrade { + default "upgrade"; + } + + upstream springboot { + server 127.0.0.1:8080; + keepalive 1024; + } + + server { + listen 80 default_server; + listen [::]:80 default_server; + + location / { + proxy_pass http://springboot; + # CORS 관련 헤더 추가 + # add_header 'Access-Control-Allow-Origin' '*'; + # add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS'; + # add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type'; + proxy_http_version 1.1; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Upgrade $http_upgrade; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + + # 타임아웃 설정 추가 + proxy_read_timeout 900s; # 백엔드 서버로부터의 응답을 기다리는 시간 + proxy_connect_timeout 900s; # 백엔드 서버에 연결을 시도하는 시간 + proxy_send_timeout 900s; # Nginx가 백엔드 서버로 요청을 전송하는 시간 + } + + access_log /var/log/nginx/access.log main; + + client_header_timeout 60; + client_body_timeout 60; + keepalive_timeout 60; + gzip off; + gzip_comp_level 4; + + # Include the Elastic Beanstalk generated locations + # include conf.d/elasticbeanstalk/healthd.conf; + } } diff --git a/build.gradle b/build.gradle index 0d09a05..8cb7934 100644 --- a/build.gradle +++ b/build.gradle @@ -39,10 +39,9 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-jackson:0.12.2' //selenium - implementation 'org.seleniumhq.selenium:selenium-java:4.1.4' - implementation 'io.github.bonigarcia:webdrivermanager:5.0.3' + implementation 'org.seleniumhq.selenium:selenium-java:4.22.0' + implementation 'io.github.bonigarcia:webdrivermanager:5.4.0' implementation 'org.jsoup:jsoup:1.13.1' - testImplementation 'org.seleniumhq.selenium:selenium-java:4.22.0' //Google Firebase implementation 'com.google.firebase:firebase-admin:9.2.0' diff --git a/src/main/java/com/cmc/suppin/SuppinApplication.java b/src/main/java/com/cmc/suppin/SuppinApplication.java index 995c389..ff74b20 100644 --- a/src/main/java/com/cmc/suppin/SuppinApplication.java +++ b/src/main/java/com/cmc/suppin/SuppinApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableJpaAuditing +@EnableScheduling public class SuppinApplication { public static void main(String[] args) { diff --git a/src/main/java/com/cmc/suppin/event/crawl/controller/CommentApi.java b/src/main/java/com/cmc/suppin/event/crawl/controller/CommentApi.java index 49a88c5..81353b3 100644 --- a/src/main/java/com/cmc/suppin/event/crawl/controller/CommentApi.java +++ b/src/main/java/com/cmc/suppin/event/crawl/controller/CommentApi.java @@ -4,6 +4,7 @@ import com.cmc.suppin.event.crawl.controller.dto.CommentResponseDTO; import com.cmc.suppin.event.crawl.service.CommentService; import com.cmc.suppin.global.response.ApiResponse; +import com.cmc.suppin.global.response.ResponseCode; import com.cmc.suppin.global.security.reslover.Account; import com.cmc.suppin.global.security.reslover.CurrentAccount; import io.swagger.v3.oas.annotations.Operation; @@ -44,7 +45,7 @@ public ResponseEntity> get } @PostMapping("/draft-winners") - @Operation(summary = "조건별 당첨자 추첨 API(댓글 이벤트)", description = "주어진 조건에 따라 이벤트의 당첨자를 추첨합니다.") + @Operation(summary = "당첨자 랜덤 추첨 결과 조회 API(댓글 이벤트)", description = "주어진 조건에 따라 이벤트의 당첨자를 추첨합니다.") public ResponseEntity> drawWinners( @RequestBody @Valid CommentRequestDTO.WinnerRequestDTO request, @CurrentAccount Account account) { @@ -61,4 +62,11 @@ public ResponseEntity>> ge List filteredWinners = commentService.getCommentsByKeyword(eventId, keyword, account.userId()); return ResponseEntity.ok(ApiResponse.of(filteredWinners)); } + + @DeleteMapping("/") + @Operation(summary = "댓글 이벤트 당첨자 리스트 삭제 API(당첨자 재추첨 시, 기존 당첨자 리스트를 삭제한 후 진행해야 합니다.", description = "모든 당첨자들의 isWinner 값을 false로 변경합니다.") + public ResponseEntity> deleteWinners(@RequestParam("eventId") Long eventId, @CurrentAccount Account account) { + commentService.deleteWinners(eventId); + return ResponseEntity.ok(ApiResponse.of(ResponseCode.SUCCESS)); + } } diff --git a/src/main/java/com/cmc/suppin/event/crawl/controller/CrawlApi.java b/src/main/java/com/cmc/suppin/event/crawl/controller/CrawlApi.java index d2948e7..a8678ac 100644 --- a/src/main/java/com/cmc/suppin/event/crawl/controller/CrawlApi.java +++ b/src/main/java/com/cmc/suppin/event/crawl/controller/CrawlApi.java @@ -1,5 +1,6 @@ package com.cmc.suppin.event.crawl.controller; +import com.cmc.suppin.event.crawl.controller.dto.CrawlResponseDTO; import com.cmc.suppin.event.crawl.service.CrawlService; import com.cmc.suppin.global.response.ApiResponse; import com.cmc.suppin.global.response.ResponseCode; @@ -51,9 +52,9 @@ public ResponseEntity> checkExistingComments(@RequestParam(" "크롤링하려는 URL이 중복되지 않았을 때의 요청이기 때문에, 새로운 댓글을 크롤링합니다.
" + "- DB에 기존 댓글이 존재하는 경우: 크롤링을 중지하고 예외를 던집니다.
" + "- DB에 기존 댓글이 존재하지 않는 경우: 새로운 댓글을 크롤링하고 이를 DB에 저장합니다.") - public ResponseEntity> crawlYoutubeComments(@RequestParam("url") String url, @RequestParam("eventId") Long eventId, @RequestParam("forceUpdate") boolean forceUpdate, @CurrentAccount Account account) { - crawlService.crawlYoutubeComments(url, eventId, account.userId(), forceUpdate); - return ResponseEntity.ok(ApiResponse.of(ResponseCode.SUCCESS, "댓글 수집이 완료되었습니다.")); + public ResponseEntity> crawlYoutubeComments(@RequestParam("url") String url, @RequestParam("eventId") Long eventId, @RequestParam("forceUpdate") boolean forceUpdate, @CurrentAccount Account account) { + CrawlResponseDTO.CrawlResultDTO crawlResultDTO = crawlService.crawlYoutubeComments(url, eventId, account.userId(), forceUpdate); + return ResponseEntity.ok(ApiResponse.of(ResponseCode.SUCCESS, crawlResultDTO)); } // @GetMapping("/count") diff --git a/src/main/java/com/cmc/suppin/event/crawl/controller/dto/CommentRequestDTO.java b/src/main/java/com/cmc/suppin/event/crawl/controller/dto/CommentRequestDTO.java index 6dd74de..14bd8a2 100644 --- a/src/main/java/com/cmc/suppin/event/crawl/controller/dto/CommentRequestDTO.java +++ b/src/main/java/com/cmc/suppin/event/crawl/controller/dto/CommentRequestDTO.java @@ -1,6 +1,5 @@ package com.cmc.suppin.event.crawl.controller.dto; -import jakarta.validation.constraints.Pattern; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -27,14 +26,10 @@ public static class CommentListRequestDTO { @Builder public static class WinnerRequestDTO { private Long eventId; - private int winnerCount; - private int minLength; - - @Pattern(regexp = "\\d{4}\\. \\d{2}\\. \\d{2} \\d{2}:\\d{2}", message = "날짜 형식은 yyyy. MM. dd HH:mm 이어야 합니다.") + private Integer winnerCount; + private Integer minLength; private String startDate; - @Pattern(regexp = "\\d{4}\\. \\d{2}\\. \\d{2} \\d{2}:\\d{2}", message = "날짜 형식은 yyyy. MM. dd HH:mm 이어야 합니다.") private String endDate; - private List keywords; } } diff --git a/src/main/java/com/cmc/suppin/event/crawl/controller/dto/CommentResponseDTO.java b/src/main/java/com/cmc/suppin/event/crawl/controller/dto/CommentResponseDTO.java index a8f5fcf..895fd67 100644 --- a/src/main/java/com/cmc/suppin/event/crawl/controller/dto/CommentResponseDTO.java +++ b/src/main/java/com/cmc/suppin/event/crawl/controller/dto/CommentResponseDTO.java @@ -41,4 +41,27 @@ public static class WinnerResponseDTO { private String endDate; private List winners; } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class CommentEventWinnersWithCriteria { + private Integer winnerCount; + private Integer minLength; + private String startDate; + private String endDate; + private List keywords; + private List winners; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class CommentEventWinners { + private String author; + private String commentText; + private String commentDate; + } } diff --git a/src/main/java/com/cmc/suppin/event/crawl/controller/dto/CrawlResponseDTO.java b/src/main/java/com/cmc/suppin/event/crawl/controller/dto/CrawlResponseDTO.java index 2df078c..33a950a 100644 --- a/src/main/java/com/cmc/suppin/event/crawl/controller/dto/CrawlResponseDTO.java +++ b/src/main/java/com/cmc/suppin/event/crawl/controller/dto/CrawlResponseDTO.java @@ -12,8 +12,7 @@ public class CrawlResponseDTO { @NoArgsConstructor @AllArgsConstructor public static class CrawlResultDTO { - private String author; - private String commentText; - private String date; + private String crawlTime; + private int totalCommentCount; } } diff --git a/src/main/java/com/cmc/suppin/event/crawl/converter/CommentConverter.java b/src/main/java/com/cmc/suppin/event/crawl/converter/CommentConverter.java index 89871b6..5cf58be 100644 --- a/src/main/java/com/cmc/suppin/event/crawl/converter/CommentConverter.java +++ b/src/main/java/com/cmc/suppin/event/crawl/converter/CommentConverter.java @@ -2,6 +2,7 @@ import com.cmc.suppin.event.crawl.controller.dto.CommentRequestDTO; import com.cmc.suppin.event.crawl.controller.dto.CommentResponseDTO; +import com.cmc.suppin.event.crawl.controller.dto.CrawlResponseDTO; import com.cmc.suppin.event.crawl.domain.Comment; import com.cmc.suppin.event.events.domain.Event; @@ -56,5 +57,20 @@ public static CommentResponseDTO.WinnerResponseDTO toWinnerResponseDTO(List { List findByEventIdAndCommentDateBetween(Long eventId, LocalDateTime start, LocalDateTime end); List findByEventIdAndCommentTextContaining(Long eventId, String keyword); + + List findByEventIdAndIsWinnerTrue(Long eventId); + + + boolean existsByUrlAndEventId(String url, Long eventId); } diff --git a/src/main/java/com/cmc/suppin/event/crawl/service/CommentService.java b/src/main/java/com/cmc/suppin/event/crawl/service/CommentService.java index 8093176..de3d477 100644 --- a/src/main/java/com/cmc/suppin/event/crawl/service/CommentService.java +++ b/src/main/java/com/cmc/suppin/event/crawl/service/CommentService.java @@ -48,12 +48,13 @@ public CommentResponseDTO.CrawledCommentListDTO getComments(Long eventId, String int totalComments = commentRepository.countByEventIdAndUrl(eventId, url); - String crawlTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy. MM. dd HH:mm")); + String crawlTime = comments.isEmpty() ? "" : comments.getContent().get(0).getCrawlTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")); return CommentConverter.toCommentListDTO(comments.getContent(), crawlTime, totalComments); } // 당첨자 조건별 랜덤 추첨(댓글 이벤트) + @Transactional public CommentResponseDTO.WinnerResponseDTO drawWinners(CommentRequestDTO.WinnerRequestDTO request, String userId) { Member member = memberRepository.findByUserIdAndStatusNot(userId, UserStatus.DELETED) .orElseThrow(() -> new IllegalArgumentException("Member not found")); @@ -61,15 +62,35 @@ public CommentResponseDTO.WinnerResponseDTO drawWinners(CommentRequestDTO.Winner Event event = eventRepository.findByIdAndMemberId(request.getEventId(), member.getId()) .orElseThrow(() -> new IllegalArgumentException("Event not found")); - DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy. MM. dd HH:mm"); - LocalDateTime startDateTime = LocalDateTime.parse(request.getStartDate(), dateTimeFormatter); - LocalDateTime endDateTime = LocalDateTime.parse(request.getEndDate(), dateTimeFormatter); + // 당첨자 선별 조건 Event 엔티티에 저장 + event.setSelectionCriteria(request); + eventRepository.save(event); - List comments = commentRepository.findByEventIdAndCommentDateBetween(event.getId(), startDateTime, endDateTime); + DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + LocalDateTime startDateTime = null; + LocalDateTime endDateTime = null; + + // 날짜 필터링을 위한 조건 설정 + if (request.getStartDate() != null && !request.getStartDate().isEmpty()) { + startDateTime = LocalDateTime.parse(request.getStartDate(), dateTimeFormatter); + } + if (request.getEndDate() != null && !request.getEndDate().isEmpty()) { + endDateTime = LocalDateTime.parse(request.getEndDate(), dateTimeFormatter); + } + + List comments; + + // 날짜 조건이 있을 경우에만 필터링, 없으면 전체 댓글을 조회 + if (startDateTime != null && endDateTime != null) { + comments = commentRepository.findByEventIdAndCommentDateBetween(event.getId(), startDateTime, endDateTime); + } else { + comments = commentRepository.findByEventId(event.getId()); + } // 키워드 필터링(OR 로직) 및 minLength 필터링 추가 List filteredComments = comments.stream() - .filter(comment -> request.getKeywords().stream().anyMatch(keyword -> comment.getCommentText().contains(keyword))) + .filter(comment -> request.getKeywords().isEmpty() || request.getKeywords().stream().anyMatch(keyword -> comment.getCommentText().contains(keyword))) .filter(comment -> comment.getCommentText().length() >= request.getMinLength()) .collect(Collectors.toList()); @@ -77,6 +98,12 @@ public CommentResponseDTO.WinnerResponseDTO drawWinners(CommentRequestDTO.Winner Collections.shuffle(filteredComments); List winners = filteredComments.stream().limit(request.getWinnerCount()).collect(Collectors.toList()); + // 당첨된 댓글의 isWinner 값을 true로 업데이트 + winners.forEach(winner -> { + winner.setIsWinner(true); + commentRepository.save(winner); // 업데이트된 Comment 엔티티를 저장 + }); + return CommentConverter.toWinnerResponseDTO(winners, request); } @@ -102,4 +129,37 @@ public List filterWinnersByKeyword(List new IllegalArgumentException("Member not found")); + + Event event = eventRepository.findByIdAndMemberId(eventId, member.getId()) + .orElseThrow(() -> new IllegalArgumentException("Event not found or does not belong to the user")); + + List winners = commentRepository.findByEventIdAndIsWinnerTrue(eventId); + + List winnerList = winners.stream() + .map(CommentConverter::toCommentEventWinners) + .collect(Collectors.toList()); + + return CommentResponseDTO.CommentEventWinnersWithCriteria.builder() + .winners(winnerList) + .winnerCount(event.getWinnerCount()) + .minLength(event.getMinLength()) + .startDate(event.getSelectionStartDate() != null ? event.getSelectionStartDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) : null) + .endDate(event.getSelectionEndDate() != null ? event.getSelectionEndDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")) : null) + .keywords(event.getKeywords()) + .build(); + } + + + public void deleteWinners(Long eventId) { + List comments = commentRepository.findByEventIdAndIsWinnerTrue(eventId); + + for (Comment comment : comments) { + comment.setIsWinner(false); + commentRepository.save(comment); + } + } + } diff --git a/src/main/java/com/cmc/suppin/event/crawl/service/CrawlService.java b/src/main/java/com/cmc/suppin/event/crawl/service/CrawlService.java index 62c4f46..015794b 100644 --- a/src/main/java/com/cmc/suppin/event/crawl/service/CrawlService.java +++ b/src/main/java/com/cmc/suppin/event/crawl/service/CrawlService.java @@ -1,5 +1,6 @@ package com.cmc.suppin.event.crawl.service; +import com.cmc.suppin.event.crawl.controller.dto.CrawlResponseDTO; import com.cmc.suppin.event.crawl.converter.CommentConverter; import com.cmc.suppin.event.crawl.converter.DateConverter; import com.cmc.suppin.event.crawl.domain.Comment; @@ -18,6 +19,7 @@ import org.jsoup.nodes.Element; import org.jsoup.select.Elements; import org.openqa.selenium.JavascriptExecutor; +import org.openqa.selenium.PageLoadStrategy; import org.openqa.selenium.WebDriver; import org.openqa.selenium.chrome.ChromeDriver; import org.openqa.selenium.chrome.ChromeOptions; @@ -25,9 +27,10 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; -import java.util.HashSet; -import java.util.List; -import java.util.Set; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; @Service @Slf4j @@ -45,14 +48,15 @@ public String checkExistingComments(String url, String userId) { List existingComments = commentRepository.findByUrl(url); if (!existingComments.isEmpty()) { - LocalDateTime firstCommentDate = existingComments.get(0).getCreatedAt(); - return "동일한 URL의 댓글을 " + firstCommentDate.toLocalDate() + " 일자에 수집한 이력이 있습니다."; + ZonedDateTime firstCommentDate = existingComments.get(0).getCrawlTime().atZone(ZoneId.of("Asia/Seoul")); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + return "동일한 URL의 댓글을 " + firstCommentDate.format(formatter) + " 일자에 수집한 이력이 있습니다."; } return null; } - public void crawlYoutubeComments(String url, Long eventId, String userId, boolean forceUpdate) { + public CrawlResponseDTO.CrawlResultDTO crawlYoutubeComments(String url, Long eventId, String userId, boolean forceUpdate) { Member member = memberRepository.findByUserIdAndStatusNot(userId, UserStatus.DELETED) .orElseThrow(() -> new IllegalArgumentException("Member not found")); @@ -60,19 +64,17 @@ public void crawlYoutubeComments(String url, Long eventId, String userId, boolea .orElseThrow(() -> new IllegalArgumentException("Event not found")); if (forceUpdate) { - // 기존 댓글 삭제 commentRepository.deleteByUrlAndEventId(url, eventId); - } else { - // 기존 댓글이 존재하는 경우: 크롤링을 중지하고 예외를 던집니다. - // 기존 댓글이 존재하지 않는 경우: 새로운 댓글을 크롤링하고 이를 DB에 저장합니다. - List existingComments = commentRepository.findByUrlAndEventId(url, eventId); - if (!existingComments.isEmpty()) { + if (commentRepository.existsByUrlAndEventId(url, eventId)) { + throw new RuntimeException("기존 댓글 삭제에 실패했습니다."); + } + } else { + if (commentRepository.existsByUrlAndEventId(url, eventId)) { throw new CrawlException(CrawlErrorCode.DUPLICATE_URL); } } - // 크롤링 코드 실행 String chromeDriverPath = System.getenv("CHROME_DRIVER_PATH"); if (chromeDriverPath != null && !chromeDriverPath.isEmpty()) { System.setProperty("webdriver.chrome.driver", chromeDriverPath); @@ -91,6 +93,8 @@ public void crawlYoutubeComments(String url, Long eventId, String userId, boolea options.addArguments("--disable-infobars"); options.addArguments("--disable-browser-side-navigation"); options.addArguments("--disable-software-rasterizer"); + options.addArguments("--blink-settings=imagesEnabled=false"); + options.setPageLoadStrategy(PageLoadStrategy.NORMAL); WebDriver driver = new ChromeDriver(options); driver.get(url); @@ -98,15 +102,38 @@ public void crawlYoutubeComments(String url, Long eventId, String userId, boolea Set uniqueComments = new HashSet<>(); try { - Thread.sleep(5000); // 초기 로딩 대기 + Thread.sleep(5000); - long endTime = System.currentTimeMillis() + 300000; // 스크롤 시간 조정 (필요에 따라 조정) JavascriptExecutor jsExecutor = (JavascriptExecutor) driver; - while (System.currentTimeMillis() < endTime) { + Queue heightQueue = new LinkedList<>(); + int maxQueueSize = 50; + int enqueueCount = 0; + int retryCount = 0; + + while (true) { jsExecutor.executeScript("window.scrollTo(0, document.documentElement.scrollHeight);"); + Thread.sleep(100); - Thread.sleep(1000); + long newPageHeight = (long) jsExecutor.executeScript("return document.documentElement.scrollHeight"); + + if (enqueueCount > maxQueueSize) { + break; + } + + if (heightQueue.isEmpty()) { + heightQueue.offer(newPageHeight); + enqueueCount++; + } else { + if (heightQueue.peek().equals(newPageHeight)) { + heightQueue.offer(newPageHeight); + enqueueCount++; + } else { + heightQueue.clear(); + heightQueue.offer(newPageHeight); + enqueueCount = 1; + } + } String pageSource = driver.getPageSource(); Document doc = Jsoup.parse(pageSource); @@ -120,18 +147,33 @@ public void crawlYoutubeComments(String url, Long eventId, String userId, boolea if (!uniqueComments.contains(text)) { uniqueComments.add(text); - // 엔티티 저장 LocalDateTime actualCommentDate = DateConverter.convertRelativeTime(time); Comment comment = CommentConverter.toCommentEntity(author, text, actualCommentDate, url, event); + + // Set crawl time with timezone + comment.setCrawlTime(ZonedDateTime.now(ZoneId.of("Asia/Seoul")).toLocalDateTime()); commentRepository.save(comment); } } + + if (comments.size() == uniqueComments.size()) { + if (retryCount >= 3) { + break; + } + retryCount++; + } else { + retryCount = 0; + } } } catch (InterruptedException e) { + Thread.currentThread().interrupt(); e.printStackTrace(); } finally { driver.quit(); } + + return CommentConverter.toCrawlResultDTO(LocalDateTime.now(), uniqueComments.size()); } } + diff --git a/src/main/java/com/cmc/suppin/event/events/controller/EventApi.java b/src/main/java/com/cmc/suppin/event/events/controller/EventApi.java index ad5b034..ce23807 100644 --- a/src/main/java/com/cmc/suppin/event/events/controller/EventApi.java +++ b/src/main/java/com/cmc/suppin/event/events/controller/EventApi.java @@ -1,10 +1,14 @@ package com.cmc.suppin.event.events.controller; +import com.cmc.suppin.event.crawl.controller.dto.CommentResponseDTO; +import com.cmc.suppin.event.crawl.service.CommentService; import com.cmc.suppin.event.events.controller.dto.EventRequestDTO; import com.cmc.suppin.event.events.controller.dto.EventResponseDTO; import com.cmc.suppin.event.events.converter.EventConverter; import com.cmc.suppin.event.events.domain.Event; import com.cmc.suppin.event.events.service.EventService; +import com.cmc.suppin.event.survey.controller.dto.SurveyResponseDTO; +import com.cmc.suppin.event.survey.service.SurveyService; import com.cmc.suppin.global.response.ApiResponse; import com.cmc.suppin.global.response.ResponseCode; import com.cmc.suppin.global.security.reslover.Account; @@ -29,6 +33,8 @@ public class EventApi { private final EventService eventService; + private final CommentService commentService; + private final SurveyService surveyService; @GetMapping("/all") @Operation(summary = "전체 이벤트 조회 API", description = "사용자의 모든 이벤트와 설문 및 댓글 수를 조회합니다.") @@ -66,4 +72,25 @@ public ResponseEntity> deleteEvent(@PathVariable("eventId") Lo eventService.deleteEvent(eventId, account.userId()); return ResponseEntity.ok(ApiResponse.of(ResponseCode.SUCCESS)); } + + @GetMapping("/comment-winners") + @Operation(summary = "댓글 이벤트 당첨자 조회 API", description = "댓글 이벤트의 당첨자 리스트와 선별 조건을 조회합니다.") + public ResponseEntity> getCommentEventWinners( + @RequestParam("eventId") Long eventId, + @CurrentAccount Account account) { + + CommentResponseDTO.CommentEventWinnersWithCriteria winnersWithCriteria = commentService.getCommentEventWinnersWithCriteria(eventId, account.userId()); + return ResponseEntity.ok(ApiResponse.of(winnersWithCriteria)); + } + + + @GetMapping("/survey-winners") + @Operation(summary = "설문 이벤트 당첨자 조회 API", description = "설문 이벤트의 당첨자 리스트 및 선별 조건을 조회합니다.") + public ResponseEntity> getSurveyEventWinners( + @RequestParam("surveyId") Long surveyId, + @CurrentAccount Account account) { + + SurveyResponseDTO.SurveyEventWinnersResponse response = surveyService.getSurveyEventWinners(surveyId, account.userId()); + return ResponseEntity.ok(ApiResponse.of(response)); + } } diff --git a/src/main/java/com/cmc/suppin/event/events/controller/dto/EventRequestDTO.java b/src/main/java/com/cmc/suppin/event/events/controller/dto/EventRequestDTO.java index bafffd1..ffeb1cc 100644 --- a/src/main/java/com/cmc/suppin/event/events/controller/dto/EventRequestDTO.java +++ b/src/main/java/com/cmc/suppin/event/events/controller/dto/EventRequestDTO.java @@ -3,7 +3,6 @@ import com.cmc.suppin.global.enums.EventType; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Pattern; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -28,13 +27,10 @@ public static class CommentEventCreateDTO { private String url; @NotEmpty - @Pattern(regexp = "\\d{4}\\. \\d{2}\\. \\d{2} \\d{2}:\\d{2}", message = "날짜 형식은 yyyy. MM. dd HH:mm 이어야 합니다.") private String startDate; @NotEmpty - @Pattern(regexp = "\\d{4}\\. \\d{2}\\. \\d{2} \\d{2}:\\d{2}", message = "날짜 형식은 yyyy. MM. dd HH:mm 이어야 합니다.") private String endDate; @NotEmpty - @Pattern(regexp = "\\d{4}\\. \\d{2}\\. \\d{2} \\d{2}:\\d{2}", message = "날짜 형식은 yyyy. MM. dd HH:mm 이어야 합니다.") private String announcementDate; } @@ -53,13 +49,10 @@ public static class SurveyEventCreateDTO { private String description; @NotEmpty - @Pattern(regexp = "\\d{4}\\. \\d{2}\\. \\d{2} \\d{2}:\\d{2}", message = "날짜 형식은 yyyy. MM. dd HH:mm 이어야 합니다.") private String startDate; @NotEmpty - @Pattern(regexp = "\\d{4}\\. \\d{2}\\. \\d{2} \\d{2}:\\d{2}", message = "날짜 형식은 yyyy. MM. dd HH:mm 이어야 합니다.") private String endDate; @NotEmpty - @Pattern(regexp = "\\d{4}\\. \\d{2}\\. \\d{2} \\d{2}:\\d{2}", message = "날짜 형식은 yyyy. MM. dd HH:mm 이어야 합니다.") private String announcementDate; } @@ -75,13 +68,10 @@ public static class EventUpdateDTO { private String url; @NotEmpty - @Pattern(regexp = "\\d{4}\\. \\d{2}\\. \\d{2} \\d{2}:\\d{2}", message = "날짜 형식은 yyyy. MM. dd HH:mm 이어야 합니다.") private String startDate; @NotEmpty - @Pattern(regexp = "\\d{4}\\. \\d{2}\\. \\d{2} \\d{2}:\\d{2}", message = "날짜 형식은 yyyy. MM. dd HH:mm 이어야 합니다.") private String endDate; @NotEmpty - @Pattern(regexp = "\\d{4}\\. \\d{2}\\. \\d{2} \\d{2}:\\d{2}", message = "날짜 형식은 yyyy. MM. dd HH:mm 이어야 합니다.") private String announcementDate; } } diff --git a/src/main/java/com/cmc/suppin/event/events/controller/dto/EventResponseDTO.java b/src/main/java/com/cmc/suppin/event/events/controller/dto/EventResponseDTO.java index 2bb0ca8..656409f 100644 --- a/src/main/java/com/cmc/suppin/event/events/controller/dto/EventResponseDTO.java +++ b/src/main/java/com/cmc/suppin/event/events/controller/dto/EventResponseDTO.java @@ -26,7 +26,8 @@ public static class EventInfoDTO { private Integer surveyCount; private Integer commentCount; private EventStatus status; - + private Long surveyId; + private String uuid; } @Builder diff --git a/src/main/java/com/cmc/suppin/event/events/converter/EventConverter.java b/src/main/java/com/cmc/suppin/event/events/converter/EventConverter.java index c0e1bfd..f6f3524 100644 --- a/src/main/java/com/cmc/suppin/event/events/converter/EventConverter.java +++ b/src/main/java/com/cmc/suppin/event/events/converter/EventConverter.java @@ -3,6 +3,7 @@ import com.cmc.suppin.event.events.controller.dto.EventRequestDTO; import com.cmc.suppin.event.events.controller.dto.EventResponseDTO; import com.cmc.suppin.event.events.domain.Event; +import com.cmc.suppin.event.survey.domain.Survey; import com.cmc.suppin.global.enums.EventType; import com.cmc.suppin.member.domain.Member; @@ -13,7 +14,7 @@ public class EventConverter { public static Event toCommentEventEntity(EventRequestDTO.CommentEventCreateDTO request, Member member) { - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy. MM. dd HH:mm"); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); return Event.builder() .type(EventType.COMMENT) .title(request.getTitle()) @@ -27,7 +28,7 @@ public static Event toCommentEventEntity(EventRequestDTO.CommentEventCreateDTO r } public static Event toSurveyEventEntity(EventRequestDTO.SurveyEventCreateDTO request, Member member) { - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy. MM. dd HH:mm"); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); return Event.builder() .type(EventType.SURVEY) .title(request.getTitle()) @@ -40,7 +41,7 @@ public static Event toSurveyEventEntity(EventRequestDTO.SurveyEventCreateDTO req } public static EventResponseDTO.CommentEventDetailDTO toEventDetailDTO(Event event) { - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy. MM. dd HH:mm"); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); return EventResponseDTO.CommentEventDetailDTO.builder() .type(event.getType()) .title(event.getTitle()) @@ -52,7 +53,7 @@ public static EventResponseDTO.CommentEventDetailDTO toEventDetailDTO(Event even } public static EventResponseDTO.EventInfoDTO toEventInfoDTO(Event event) { - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy. MM. dd HH:mm"); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); Optional url = Optional.empty(); if (event.getType() == EventType.COMMENT) { @@ -64,6 +65,11 @@ public static EventResponseDTO.EventInfoDTO toEventInfoDTO(Event event) { .mapToInt(survey -> survey.getAnonymousParticipantList().size()) .sum(); + // 이벤트에 설문이 존재하는 경우 surveyId와 uuid 값을 설정 + Survey survey = event.getSurveyList().stream().findFirst().orElse(null); + Long surveyId = (survey != null) ? survey.getId() : null; + String uuid = (survey != null) ? survey.getUuid() : null; + return EventResponseDTO.EventInfoDTO.builder() .eventId(event.getId()) .type(event.getType()) @@ -75,11 +81,13 @@ public static EventResponseDTO.EventInfoDTO toEventInfoDTO(Event event) { .surveyCount(surveyAnswerCount) .commentCount(event.getCommentList().size()) .status(event.getStatus()) + .surveyId(surveyId) + .uuid(uuid) .build(); } public static Event toUpdatedEventEntity(EventRequestDTO.EventUpdateDTO request, Member member) { - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy. MM. dd HH:mm"); + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); Event.EventBuilder eventBuilder = Event.builder() .title(request.getTitle()) .description(request.getDescription()) diff --git a/src/main/java/com/cmc/suppin/event/events/domain/Event.java b/src/main/java/com/cmc/suppin/event/events/domain/Event.java index bd3e0e0..e3d1bd6 100644 --- a/src/main/java/com/cmc/suppin/event/events/domain/Event.java +++ b/src/main/java/com/cmc/suppin/event/events/domain/Event.java @@ -1,5 +1,6 @@ package com.cmc.suppin.event.events.domain; +import com.cmc.suppin.event.crawl.controller.dto.CommentRequestDTO; import com.cmc.suppin.event.crawl.domain.Comment; import com.cmc.suppin.event.survey.domain.Survey; import com.cmc.suppin.global.domain.BaseDateTimeEntity; @@ -11,6 +12,7 @@ import org.hibernate.annotations.DynamicInsert; import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; @@ -63,6 +65,24 @@ public class Event extends BaseDateTimeEntity { @Enumerated(EnumType.STRING) private EventStatus status; + // 당첨자 선별 조건 + @Column + private Integer winnerCount; + + @Column + private LocalDateTime selectionStartDate; + + @Column + private LocalDateTime selectionEndDate; + + @Column + private Integer minLength; + + @ElementCollection + @CollectionTable(name = "event_keywords", joinColumns = @JoinColumn(name = "event_id")) + @Column(name = "keyword") + private List keywords; + public void setMember(Member member) { this.member = member; member.getEventList().add(this); @@ -75,4 +95,51 @@ public void setStatus(EventStatus status) { public void setId(Long id) { this.id = id; } + + public void setWinnerCount(Integer winnerCount) { + this.winnerCount = winnerCount; + } + + public void setSelectionStartDate(LocalDateTime selectionStartDate) { + this.selectionStartDate = selectionStartDate; + } + + public void setSelectionEndDate(LocalDateTime selectionEndDate) { + this.selectionEndDate = selectionEndDate; + } + + public void setMinLength(Integer minLength) { + this.minLength = minLength; + } + + public void setKeywords(List keywords) { + this.keywords = keywords; + } + + public void setSelectionCriteria(CommentRequestDTO.WinnerRequestDTO request) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + + this.winnerCount = request.getWinnerCount(); + + // startDate와 endDate가 빈 문자열("")이거나 null일 경우 null로 설정 + if (request.getStartDate() == null || request.getStartDate().isEmpty()) { + this.selectionStartDate = null; // 필터링을 하지 않도록 null로 설정 + } else { + this.selectionStartDate = LocalDateTime.parse(request.getStartDate(), formatter); + } + + if (request.getEndDate() == null || request.getEndDate().isEmpty()) { + this.selectionEndDate = null; // 필터링을 하지 않도록 null로 설정 + } else { + this.selectionEndDate = LocalDateTime.parse(request.getEndDate(), formatter); + } + + this.minLength = request.getMinLength(); + + // keywords가 빈 문자열만 있을 경우 처리 + this.keywords = (request.getKeywords() == null || request.getKeywords().isEmpty() || + (request.getKeywords().size() == 1 && request.getKeywords().get(0).isEmpty())) ? + new ArrayList<>() : request.getKeywords(); + } + } diff --git a/src/main/java/com/cmc/suppin/event/survey/controller/SurveyApi.java b/src/main/java/com/cmc/suppin/event/survey/controller/SurveyApi.java index abba461..627ff17 100644 --- a/src/main/java/com/cmc/suppin/event/survey/controller/SurveyApi.java +++ b/src/main/java/com/cmc/suppin/event/survey/controller/SurveyApi.java @@ -34,10 +34,24 @@ public ResponseEntity> creat return ResponseEntity.ok(ApiResponse.of(response)); } - @GetMapping("/{surveyId}") - @Operation(summary = "설문지 조회 API", description = "생성된 설문지 전체 정보를 조회합니다. 자세한 요청 및 응답 형식은 노션 API 문서를 참고해주세요.") - public ResponseEntity> getSurvey(@PathVariable("surveyId") Long surveyId) { - SurveyResponseDTO.SurveyResultDTO response = surveyService.getSurvey(surveyId); + @GetMapping("/view") + @Operation(summary = "설문지 조회 API", description = "생성된 설문지 전체 정보를 조회합니다. surveyId와 uuid, 둘 중 하나로 요청할 수 있습니다.") + public ResponseEntity> getSurvey( + @Parameter(description = "required = false") + @RequestParam(value = "surveyId", required = false) Long surveyId, + @Parameter(description = "required = false") + @RequestParam(value = "uuid", required = false) String uuid) { + + SurveyResponseDTO.SurveyViewDTO response; + + if (uuid != null) { + response = surveyService.getSurveyByUuid(uuid); + } else if (surveyId != null) { + response = surveyService.getSurveyBySurveyId(surveyId); + } else { + throw new IllegalArgumentException("Either surveyId or uuid must be provided"); + } + return ResponseEntity.ok(ApiResponse.of(response)); } @@ -63,7 +77,7 @@ public ResponseEntity> getS } @PostMapping("/draft") - @Operation(summary = "당첨자 랜덤 추첨 결과 리스트 조회 API(설문 이벤트)", + @Operation(summary = "당첨자 랜덤 추첨 결과 조회 API(설문 이벤트)", description = "주관식 답변 중 조건을 설정하여 랜덤으로 당첨자를 추첨합니다. 추첨된 당첨자의 isWinner값이 True로 설정됩니다. " + "자세한 요청 및 응답 형식은 노션 API 문서를 참고해주세요.") public ResponseEntity> selectRandomWinners( @RequestBody @Valid SurveyRequestDTO.RandomSelectionRequestDTO request, @CurrentAccount Account account) { @@ -81,7 +95,7 @@ public ResponseEntity> getWinnerD } @DeleteMapping("/winners") - @Operation(summary = "당첨자 리스트 삭제 API(당첨자 재추첨 시, 기존 당첨자 리스트를 삭제 후 진행 해야합니다.)", description = "해당 설문조사의 모든 당첨자들의 isWinner 값을 false로 변경합니다.") + @Operation(summary = "설문 이벤트 당첨자 리스트 삭제 API(당첨자 재추첨 시, 기존 당첨자 리스트를 삭제 후 진행해야 합니다.)", description = "해당 설문조사의 모든 당첨자들의 isWinner 값을 false로 변경합니다.") public ResponseEntity> deleteWinners(@RequestParam("surveyId") Long surveyId) { surveyService.deleteWinners(surveyId); return ResponseEntity.ok(ApiResponse.of(ResponseCode.SUCCESS)); diff --git a/src/main/java/com/cmc/suppin/event/survey/controller/dto/SurveyRequestDTO.java b/src/main/java/com/cmc/suppin/event/survey/controller/dto/SurveyRequestDTO.java index 992e07b..2b9ed97 100644 --- a/src/main/java/com/cmc/suppin/event/survey/controller/dto/SurveyRequestDTO.java +++ b/src/main/java/com/cmc/suppin/event/survey/controller/dto/SurveyRequestDTO.java @@ -9,7 +9,6 @@ import lombok.Getter; import lombok.NoArgsConstructor; -import java.time.LocalDateTime; import java.util.List; public class SurveyRequestDTO { @@ -22,6 +21,7 @@ public class SurveyRequestDTO { public static class SurveyCreateDTO { @NotNull private Long eventId; + private String consentFormHtml; // 개인정보 수집 동의서 HTML 필드 private List personalInfoOptionList; private List questionList; @@ -66,7 +66,8 @@ public static class SurveyAnswerDTO { @Builder public static class ParticipantDTO { private String name; - private String address; + private String fullAddress; + private String extraAddress; private String email; private String phoneNumber; private String instagramId; @@ -89,7 +90,6 @@ public static class AnswerDTO { @AllArgsConstructor @Builder public static class AnswerOptionDTO { - @NotNull private Long questionOptionId; } } @@ -107,9 +107,9 @@ public static class RandomSelectionRequestDTO { @NotNull private Integer winnerCount; @NotNull - private LocalDateTime startDate; + private String startDate; @NotNull - private LocalDateTime endDate; + private String endDate; @NotNull private Integer minLength; @NotNull diff --git a/src/main/java/com/cmc/suppin/event/survey/controller/dto/SurveyResponseDTO.java b/src/main/java/com/cmc/suppin/event/survey/controller/dto/SurveyResponseDTO.java index a70df15..4ef5d45 100644 --- a/src/main/java/com/cmc/suppin/event/survey/controller/dto/SurveyResponseDTO.java +++ b/src/main/java/com/cmc/suppin/event/survey/controller/dto/SurveyResponseDTO.java @@ -24,14 +24,16 @@ public static class SurveyCreateResponse { @NoArgsConstructor @AllArgsConstructor @Builder - public static class SurveyResultDTO { + public static class SurveyViewDTO { private Long eventId; private String eventTitle; private String eventDescription; private String startDate; private String endDate; private String announcementDate; + private String consentFormHtml; private List personalInfoOptions; + private Long surveyId; private List questions; @Getter @@ -39,9 +41,19 @@ public static class SurveyResultDTO { @AllArgsConstructor @Builder public static class QuestionDTO { + private Long questionId; private QuestionType questionType; private String questionText; - private List options; + private List options; + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class OptionDTO { + private Long questionOptionId; + private String optionText; + } } @Getter @@ -49,7 +61,7 @@ public static class QuestionDTO { @AllArgsConstructor @Builder public static class PersonalInfoOptionDTO { - private String optionName; + private String option; } } @@ -113,7 +125,8 @@ public static class SelectionCriteriaDTO { public static class WinnerDetailDTO { private String name; private String phoneNumber; - private String address; + private String fullAddress; + private String extraAddress; private String email; private String instagramId; private List answers; @@ -128,4 +141,36 @@ public static class AnswerDetailDTO { private List selectedOptions; // 객관식 질문의 경우 선택된 옵션 리스트 } } + + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class SurveyEventWinners { + private String name; + private List answers; + } + + // 당첨자 선별 조건도 포함하여 반환하기 위한 DTO + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class SurveyEventWinnersResponse { + private SelectionCriteriaDTO selectionCriteria; + private List winners; + + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class SelectionCriteriaDTO { + private Integer winnerCount; + private LocalDateTime selectionStartDate; + private LocalDateTime selectionEndDate; + private Integer minLength; + private List keywords; + } + } } diff --git a/src/main/java/com/cmc/suppin/event/survey/converter/SurveyConverter.java b/src/main/java/com/cmc/suppin/event/survey/converter/SurveyConverter.java index 498f47f..3585f51 100644 --- a/src/main/java/com/cmc/suppin/event/survey/converter/SurveyConverter.java +++ b/src/main/java/com/cmc/suppin/event/survey/converter/SurveyConverter.java @@ -6,15 +6,18 @@ import com.cmc.suppin.event.survey.domain.*; import org.springframework.data.domain.Page; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.List; import java.util.stream.Collectors; public class SurveyConverter { - public static Survey toSurveyEntity(Event event, String uuid) { + public static Survey toSurveyEntity(Event event, String uuid, String consentFormHtml) { return Survey.builder() .event(event) .uuid(uuid) + .consentFormHtml(consentFormHtml) .build(); } @@ -44,35 +47,42 @@ public static List toPersonalInfoCollectOptionEntitie .collect(Collectors.toList()); } - public static SurveyResponseDTO.SurveyResultDTO toSurveyResultDTO(Survey survey, Event event) { - List personalInfoOptions = survey.getPersonalInfoList().stream() - .map(option -> SurveyResponseDTO.SurveyResultDTO.PersonalInfoOptionDTO.builder() - .optionName(option.getOptionName()) + public static SurveyResponseDTO.SurveyViewDTO toSurveyViewResultDTO(Survey survey, Event event) { + List personalInfoOptions = survey.getPersonalInfoList().stream() + .map(option -> SurveyResponseDTO.SurveyViewDTO.PersonalInfoOptionDTO.builder() + .option(option.getOptionName()) .build()) .collect(Collectors.toList()); - List questions = survey.getQuestionList().stream() - .map(question -> SurveyResponseDTO.SurveyResultDTO.QuestionDTO.builder() + List questions = survey.getQuestionList().stream() + .map(question -> SurveyResponseDTO.SurveyViewDTO.QuestionDTO.builder() + .questionId(question.getId()) .questionType(question.getQuestionType()) .questionText(question.getQuestionText()) .options(question.getQuestionOptionList().stream() - .map(QuestionOption::getOptionText) + .map(option -> SurveyResponseDTO.SurveyViewDTO.QuestionDTO.OptionDTO.builder() + .questionOptionId(option.getId()) + .optionText(option.getOptionText()) + .build()) .collect(Collectors.toList())) .build()) .collect(Collectors.toList()); - return SurveyResponseDTO.SurveyResultDTO.builder() + return SurveyResponseDTO.SurveyViewDTO.builder() .eventId(event.getId()) .eventTitle(event.getTitle()) .eventDescription(event.getDescription()) .startDate(event.getStartDate().toString()) .endDate(event.getEndDate().toString()) .announcementDate(event.getAnnouncementDate().toString()) + .consentFormHtml(survey.getConsentFormHtml()) .personalInfoOptions(personalInfoOptions) + .surveyId(survey.getId()) .questions(questions) .build(); } + public static SurveyResponseDTO.SurveyAnswerResultDTO toSurveyAnswerResultDTO(Question question, Page answersPage) { List answers = answersPage.stream() .map(answer -> SurveyResponseDTO.SurveyAnswerResultDTO.AnswerDTO.builder() @@ -97,7 +107,8 @@ public static AnonymousParticipant toAnonymousParticipant(SurveyRequestDTO.Surve return AnonymousParticipant.builder() .survey(survey) .name(dto.getName()) - .address(dto.getAddress()) + .fullAddress(dto.getFullAddress()) + .extraAddress(dto.getExtraAddress()) .email(dto.getEmail()) .phoneNumber(dto.getPhoneNumber()) .isAgreed(dto.getIsAgreed()) @@ -120,10 +131,12 @@ public static AnswerOption toAnswerOption(SurveyRequestDTO.SurveyAnswerDTO.Answe } public static SurveyResponseDTO.RandomSelectionResponseDTO.SelectionCriteriaDTO toSelectionCriteriaDTO(SurveyRequestDTO.RandomSelectionRequestDTO request) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + return SurveyResponseDTO.RandomSelectionResponseDTO.SelectionCriteriaDTO.builder() .winnerCount(request.getWinnerCount()) - .startDate(request.getStartDate()) - .endDate(request.getEndDate()) + .startDate(LocalDateTime.parse(request.getStartDate(), formatter)) + .endDate(LocalDateTime.parse(request.getEndDate(), formatter)) .minLength(request.getMinLength()) .keywords(request.getKeywords()) .build(); @@ -148,10 +161,27 @@ public static SurveyResponseDTO.WinnerDetailDTO toWinnerDetailDTO(AnonymousParti return SurveyResponseDTO.WinnerDetailDTO.builder() .name(participant.getName()) .phoneNumber(participant.getPhoneNumber()) - .address(participant.getAddress()) + .fullAddress(participant.getFullAddress()) + .extraAddress(participant.getExtraAddress()) .email(participant.getEmail()) .instagramId(participant.getInstagramId()) .answers(answers) .build(); } + + public static SurveyResponseDTO.SurveyEventWinners toSurveyEventWinners(AnonymousParticipant participant) { + return SurveyResponseDTO.SurveyEventWinners.builder() + .name(participant.getName()) + .answers(participant.getAnswerList().stream() + .map(answer -> SurveyResponseDTO.WinnerDetailDTO.AnswerDetailDTO.builder() + .questionText(answer.getQuestion().getQuestionText()) + .answerText(answer.getAnswerText()) + .selectedOptions(answer.getAnswerOptionList().stream() + .map(answerOption -> answerOption.getQuestionOption().getOptionText()) + .collect(Collectors.toList())) + .build()) + .collect(Collectors.toList())) + .build(); + } + } diff --git a/src/main/java/com/cmc/suppin/event/survey/domain/AnonymousParticipant.java b/src/main/java/com/cmc/suppin/event/survey/domain/AnonymousParticipant.java index 5bdac04..7c2c22b 100644 --- a/src/main/java/com/cmc/suppin/event/survey/domain/AnonymousParticipant.java +++ b/src/main/java/com/cmc/suppin/event/survey/domain/AnonymousParticipant.java @@ -25,11 +25,14 @@ public class AnonymousParticipant extends BaseDateTimeEntity { private Survey survey; @OneToMany(mappedBy = "anonymousParticipant") + @Builder.Default private List answerList = new ArrayList<>(); private String name; - private String address; + private String fullAddress; + + private String extraAddress; private String email; @@ -41,11 +44,19 @@ public class AnonymousParticipant extends BaseDateTimeEntity { @Column(nullable = false) private Boolean isAgreed; - private Boolean isWinner; + @Builder.Default + @Column(nullable = false) + private Boolean isWinner = false; - private Boolean isChecked; + @Builder.Default + @Column(nullable = false) + private Boolean isChecked = false; public void setIsWinner(Boolean isWinner) { this.isWinner = isWinner; } + + public void setIsChecked(Boolean isChecked) { + this.isChecked = isChecked; + } } diff --git a/src/main/java/com/cmc/suppin/event/survey/domain/Answer.java b/src/main/java/com/cmc/suppin/event/survey/domain/Answer.java index 4cbd141..073f89e 100644 --- a/src/main/java/com/cmc/suppin/event/survey/domain/Answer.java +++ b/src/main/java/com/cmc/suppin/event/survey/domain/Answer.java @@ -30,6 +30,7 @@ public class Answer extends BaseDateTimeEntity { private AnonymousParticipant anonymousParticipant; @OneToMany(mappedBy = "answer") + @Builder.Default private List answerOptionList = new ArrayList<>(); @Column(columnDefinition = "TEXT") diff --git a/src/main/java/com/cmc/suppin/event/survey/domain/Question.java b/src/main/java/com/cmc/suppin/event/survey/domain/Question.java index 98c86a5..d5b1d92 100644 --- a/src/main/java/com/cmc/suppin/event/survey/domain/Question.java +++ b/src/main/java/com/cmc/suppin/event/survey/domain/Question.java @@ -25,9 +25,11 @@ public class Question { private Survey survey; @OneToMany(mappedBy = "question") + @Builder.Default private List questionOptionList = new ArrayList<>(); @OneToMany(mappedBy = "question") + @Builder.Default private List answerList = new ArrayList<>(); @Enumerated(EnumType.STRING) diff --git a/src/main/java/com/cmc/suppin/event/survey/domain/QuestionOption.java b/src/main/java/com/cmc/suppin/event/survey/domain/QuestionOption.java index d21d337..44fc85a 100644 --- a/src/main/java/com/cmc/suppin/event/survey/domain/QuestionOption.java +++ b/src/main/java/com/cmc/suppin/event/survey/domain/QuestionOption.java @@ -27,6 +27,7 @@ public class QuestionOption { private String optionText; @OneToMany(mappedBy = "questionOption") + @Builder.Default private List answerOptionList = new ArrayList<>(); } diff --git a/src/main/java/com/cmc/suppin/event/survey/domain/Survey.java b/src/main/java/com/cmc/suppin/event/survey/domain/Survey.java index f359fb1..d9d797e 100644 --- a/src/main/java/com/cmc/suppin/event/survey/domain/Survey.java +++ b/src/main/java/com/cmc/suppin/event/survey/domain/Survey.java @@ -26,12 +26,15 @@ public class Survey extends BaseDateTimeEntity { private Event event; @OneToMany(mappedBy = "survey", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default private List personalInfoList = new ArrayList<>(); @OneToMany(mappedBy = "survey") + @Builder.Default private List questionList = new ArrayList<>(); @OneToMany(mappedBy = "survey") + @Builder.Default private List anonymousParticipantList = new ArrayList<>(); @Column(columnDefinition = "TEXT") @@ -39,5 +42,8 @@ public class Survey extends BaseDateTimeEntity { @Column(nullable = false, updatable = false, unique = true) private String uuid; + + @Column(columnDefinition = "TEXT") + private String consentFormHtml; } diff --git a/src/main/java/com/cmc/suppin/event/survey/domain/repository/AnonymousParticipantRepository.java b/src/main/java/com/cmc/suppin/event/survey/domain/repository/AnonymousParticipantRepository.java index 6e94add..9d894dc 100644 --- a/src/main/java/com/cmc/suppin/event/survey/domain/repository/AnonymousParticipantRepository.java +++ b/src/main/java/com/cmc/suppin/event/survey/domain/repository/AnonymousParticipantRepository.java @@ -1,6 +1,7 @@ package com.cmc.suppin.event.survey.domain.repository; import com.cmc.suppin.event.survey.domain.AnonymousParticipant; +import com.cmc.suppin.event.survey.domain.Survey; import org.springframework.data.jpa.repository.JpaRepository; import java.util.List; @@ -13,4 +14,5 @@ public interface AnonymousParticipantRepository extends JpaRepository findBySurveyIdAndIsWinnerTrue(Long surveyId); + List findBySurveyAndIsWinnerTrue(Survey survey); } diff --git a/src/main/java/com/cmc/suppin/event/survey/domain/repository/SurveyRepository.java b/src/main/java/com/cmc/suppin/event/survey/domain/repository/SurveyRepository.java index afb0fc2..ba0a5ea 100644 --- a/src/main/java/com/cmc/suppin/event/survey/domain/repository/SurveyRepository.java +++ b/src/main/java/com/cmc/suppin/event/survey/domain/repository/SurveyRepository.java @@ -3,7 +3,8 @@ import com.cmc.suppin.event.survey.domain.Survey; import org.springframework.data.jpa.repository.JpaRepository; -public interface SurveyRepository extends JpaRepository { - +import java.util.Optional; +public interface SurveyRepository extends JpaRepository { + Optional findByUuid(String uuid); } diff --git a/src/main/java/com/cmc/suppin/event/survey/service/SurveyService.java b/src/main/java/com/cmc/suppin/event/survey/service/SurveyService.java index aff87de..26fe64b 100644 --- a/src/main/java/com/cmc/suppin/event/survey/service/SurveyService.java +++ b/src/main/java/com/cmc/suppin/event/survey/service/SurveyService.java @@ -20,6 +20,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.Collections; import java.util.List; import java.util.UUID; @@ -54,7 +56,7 @@ public SurveyResponseDTO.SurveyCreateResponse createSurvey(SurveyRequestDTO.Surv // Survey 엔티티 생성 및 저장 String uuid = UUID.randomUUID().toString(); - Survey survey = SurveyConverter.toSurveyEntity(event, uuid); + Survey survey = SurveyConverter.toSurveyEntity(event, uuid, request.getConsentFormHtml()); surveyRepository.save(survey); // 각 개인정보 항목 처리 및 저장 @@ -84,12 +86,21 @@ public SurveyResponseDTO.SurveyCreateResponse createSurvey(SurveyRequestDTO.Surv // 생성된 설문지 조회 @Transactional(readOnly = true) - public SurveyResponseDTO.SurveyResultDTO getSurvey(Long surveyId) { + public SurveyResponseDTO.SurveyViewDTO getSurveyBySurveyId(Long surveyId) { Survey survey = surveyRepository.findById(surveyId) .orElseThrow(() -> new IllegalArgumentException("Survey not found")); Event event = survey.getEvent(); - return SurveyConverter.toSurveyResultDTO(survey, event); + return SurveyConverter.toSurveyViewResultDTO(survey, event); + } + + @Transactional(readOnly = true) + public SurveyResponseDTO.SurveyViewDTO getSurveyByUuid(String uuid) { + Survey survey = surveyRepository.findByUuid(uuid) + .orElseThrow(() -> new IllegalArgumentException("Survey not found for UUID: " + uuid)); + + Event event = survey.getEvent(); + return SurveyConverter.toSurveyViewResultDTO(survey, event); } // 설문 응답 저장 @@ -157,6 +168,19 @@ public SurveyResponseDTO.RandomSelectionResponseDTO selectRandomWinners(SurveyRe Question question = questionRepository.findByIdAndSurveyId(request.getQuestionId(), request.getSurveyId()) .orElseThrow(() -> new IllegalArgumentException("Question not found for the given survey")); + Event event = survey.getEvent(); + + // 선별 조건 Event 엔티티에 저장 + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + event.setWinnerCount(request.getWinnerCount()); + event.setSelectionStartDate(LocalDateTime.parse(request.getStartDate(), formatter)); + event.setSelectionEndDate(LocalDateTime.parse(request.getEndDate(), formatter)); + event.setMinLength(request.getMinLength()); + event.setKeywords(request.getKeywords()); + + // Event 엔티티 업데이트 (저장) + eventRepository.save(event); + // 키워드를 OR 조건으로 연결 List keywordPatterns = request.getKeywords().stream() .map(keyword -> "%" + keyword.toLowerCase() + "%") @@ -164,7 +188,7 @@ public SurveyResponseDTO.RandomSelectionResponseDTO selectRandomWinners(SurveyRe // 조건에 맞는 주관식 답변 조회 List eligibleAnswers = answerCustomRepository.findEligibleAnswers( - request.getQuestionId(), request.getStartDate(), request.getEndDate(), + request.getQuestionId(), LocalDateTime.parse(request.getStartDate(), formatter), LocalDateTime.parse(request.getEndDate(), formatter), request.getMinLength(), request.getKeywords()); // 랜덤 추첨 @@ -218,4 +242,36 @@ public void deleteWinners(Long surveyId) { anonymousParticipantRepository.save(participant); } } + + public SurveyResponseDTO.SurveyEventWinnersResponse getSurveyEventWinners(Long surveyId, String userId) { + Member member = memberRepository.findByUserIdAndStatusNot(userId, UserStatus.DELETED) + .orElseThrow(() -> new IllegalArgumentException("Member not found")); + + Survey survey = surveyRepository.findById(surveyId) + .orElseThrow(() -> new IllegalArgumentException("Survey not found")); + + Event event = survey.getEvent(); + + // 당첨자 리스트 가져오기 + List winners = anonymousParticipantRepository.findBySurveyAndIsWinnerTrue(survey); + + List winnerList = winners.stream() + .map(SurveyConverter::toSurveyEventWinners) + .collect(Collectors.toList()); + + // 선별 조건 가져오기 + SurveyResponseDTO.SurveyEventWinnersResponse.SelectionCriteriaDTO criteria = SurveyResponseDTO.SurveyEventWinnersResponse.SelectionCriteriaDTO.builder() + .winnerCount(event.getWinnerCount()) + .selectionStartDate(event.getSelectionStartDate()) + .selectionEndDate(event.getSelectionEndDate()) + .minLength(event.getMinLength()) + .keywords(event.getKeywords()) + .build(); + + return SurveyResponseDTO.SurveyEventWinnersResponse.builder() + .selectionCriteria(criteria) + .winners(winnerList) + .build(); + } + } diff --git a/src/main/java/com/cmc/suppin/fcm/controller/FcmController.java b/src/main/java/com/cmc/suppin/fcm/controller/FcmController.java index 69c04fa..de77adc 100644 --- a/src/main/java/com/cmc/suppin/fcm/controller/FcmController.java +++ b/src/main/java/com/cmc/suppin/fcm/controller/FcmController.java @@ -1,9 +1,14 @@ package com.cmc.suppin.fcm.controller; +import com.cmc.suppin.fcm.controller.dto.DeviceTokenRequestDTO; import com.cmc.suppin.fcm.controller.dto.FcmSendDTO; import com.cmc.suppin.fcm.service.FcmService; import com.cmc.suppin.global.response.ApiResponse; +import com.cmc.suppin.global.security.reslover.Account; +import com.cmc.suppin.global.security.reslover.CurrentAccount; +import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; @@ -16,22 +21,26 @@ @Slf4j @RestController +@RequiredArgsConstructor @Tag(name = "FCM", description = "FCM 푸시알림 관련 API") @RequestMapping("/api/v1/fcm") public class FcmController { private final FcmService fcmService; - public FcmController(FcmService fcmService) { - this.fcmService = fcmService; - } - - // 모바일로부터 사용자 FCM 토큰, 메시지 제목, 내용을 받아서 서비스를 처리 @PostMapping("/send") public ResponseEntity> pushMessage(@RequestBody @Validated FcmSendDTO fcmSendDto) throws IOException { - log.debug("[+] 푸시 메시지를 전송합니다. "); + log.debug("[+] 푸시 메시지를 전송합니다."); int result = fcmService.sendMessageTo(fcmSendDto); return ResponseEntity.ok(ApiResponse.of(result)); } + + @PostMapping("/register") + @Operation(summary = "FCM 디바이스 토큰 등록 API", description = "앱을 시작할 때, 디바이스 토큰을 저장합니다.

DeviceType : ANDROID, IOS, OTHER") + public ResponseEntity registerDeviceToken(@RequestBody @Validated DeviceTokenRequestDTO request, @CurrentAccount Account account) { + fcmService.registerDeviceToken(account.userId(), request.getToken(), request.getDeviceType()); + return ResponseEntity.ok().build(); + } } + diff --git a/src/main/java/com/cmc/suppin/fcm/controller/dto/DeviceTokenRequestDTO.java b/src/main/java/com/cmc/suppin/fcm/controller/dto/DeviceTokenRequestDTO.java new file mode 100644 index 0000000..5083873 --- /dev/null +++ b/src/main/java/com/cmc/suppin/fcm/controller/dto/DeviceTokenRequestDTO.java @@ -0,0 +1,14 @@ +package com.cmc.suppin.fcm.controller.dto; + +import com.cmc.suppin.fcm.domain.DeviceType; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class DeviceTokenRequestDTO { + private String token; + private DeviceType deviceType; // ANDROID, IOS, OTHER +} diff --git a/src/main/java/com/cmc/suppin/fcm/domain/DeviceToken.java b/src/main/java/com/cmc/suppin/fcm/domain/DeviceToken.java new file mode 100644 index 0000000..5089e51 --- /dev/null +++ b/src/main/java/com/cmc/suppin/fcm/domain/DeviceToken.java @@ -0,0 +1,47 @@ +package com.cmc.suppin.fcm.domain; + +import com.cmc.suppin.member.domain.Member; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.DynamicInsert; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +@DynamicInsert +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class DeviceToken { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Column(nullable = false, unique = true) + private String deviceToken; + + @Enumerated(EnumType.STRING) + private DeviceType deviceType; // ANDROID, IOS, OTHER + + @Column(nullable = false) + private LocalDateTime createdAt; + + + public void setMember(Member member) { + this.member = member; + } + + public void setToken(String token) { + this.deviceToken = token; + } + + public void setCreatedAt(LocalDateTime now) { + this.createdAt = now; + } +} diff --git a/src/main/java/com/cmc/suppin/fcm/domain/DeviceType.java b/src/main/java/com/cmc/suppin/fcm/domain/DeviceType.java new file mode 100644 index 0000000..c69c297 --- /dev/null +++ b/src/main/java/com/cmc/suppin/fcm/domain/DeviceType.java @@ -0,0 +1,5 @@ +package com.cmc.suppin.fcm.domain; + +public enum DeviceType { + ANDROID, IOS, OTHER +} diff --git a/src/main/java/com/cmc/suppin/fcm/domain/respository/DeviceTokenRepository.java b/src/main/java/com/cmc/suppin/fcm/domain/respository/DeviceTokenRepository.java new file mode 100644 index 0000000..0bbaab8 --- /dev/null +++ b/src/main/java/com/cmc/suppin/fcm/domain/respository/DeviceTokenRepository.java @@ -0,0 +1,17 @@ +package com.cmc.suppin.fcm.domain.respository; + +import com.cmc.suppin.fcm.domain.DeviceToken; +import com.cmc.suppin.member.domain.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface DeviceTokenRepository extends JpaRepository { + Optional findByDeviceToken(String deviceToken); + + List findAllByMember(Member member); +} + diff --git a/src/main/java/com/cmc/suppin/fcm/service/FcmService.java b/src/main/java/com/cmc/suppin/fcm/service/FcmService.java index 8790906..512ba8e 100644 --- a/src/main/java/com/cmc/suppin/fcm/service/FcmService.java +++ b/src/main/java/com/cmc/suppin/fcm/service/FcmService.java @@ -1,12 +1,128 @@ package com.cmc.suppin.fcm.service; +import com.cmc.suppin.fcm.controller.dto.FcmMessageDTO; import com.cmc.suppin.fcm.controller.dto.FcmSendDTO; +import com.cmc.suppin.fcm.domain.DeviceToken; +import com.cmc.suppin.fcm.domain.DeviceType; +import com.cmc.suppin.fcm.domain.respository.DeviceTokenRepository; +import com.cmc.suppin.global.enums.UserStatus; +import com.cmc.suppin.member.domain.Member; +import com.cmc.suppin.member.domain.repository.MemberRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.auth.oauth2.GoogleCredentials; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.ClassPathResource; +import org.springframework.http.*; +import org.springframework.http.converter.StringHttpMessageConverter; import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.List; @Service -public interface FcmService { +@Slf4j +@RequiredArgsConstructor +public class FcmService { - int sendMessageTo(FcmSendDTO fcmSendDTO) throws IOException; + private final DeviceTokenRepository deviceTokenRepository; + private final MemberRepository memberRepository; + + /** + * 푸시 메시지 처리를 수행하는 비즈니스 로직 + * + * @param fcmSendDTO 모바일에서 전달받은 Object + * @return 성공(1), 실패(0) + */ + public int sendMessageTo(FcmSendDTO fcmSendDTO) throws IOException { + + try { + String message = makeMessage(fcmSendDTO); + RestTemplate restTemplate = new RestTemplate(); + + restTemplate.getMessageConverters().add(0, new StringHttpMessageConverter(StandardCharsets.UTF_8)); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("Authorization", "Bearer " + getAccessToken()); + + HttpEntity entity = new HttpEntity<>(message, headers); + + String API_URL = "https://fcm.googleapis.com/v1/projects/suppin-a5657/messages:send"; + ResponseEntity response = restTemplate.exchange(API_URL, HttpMethod.POST, entity, String.class); + + if (response.getStatusCode() == HttpStatus.OK) { + return 1; + } else { + log.error("FCM 메시지 전송 실패: {}", response.getStatusCode()); + return 0; + } + } catch (Exception e) { + log.error("FCM 메시지 전송 중 예외 발생", e); + return 0; + } + } + + /** + * Firebase Admin SDK의 비공개 키를 참조하여 Bearer 토큰을 발급 받습니다. + * + * @return Bearer token, String + */ + private String getAccessToken() throws IOException { + String firebaseConfigPath = "firebase/suppin-a5657-firebase-adminsdk.json"; + + GoogleCredentials googleCredentials = GoogleCredentials + .fromStream(new ClassPathResource(firebaseConfigPath).getInputStream()) + .createScoped(List.of("")); + + googleCredentials.refreshIfExpired(); + return googleCredentials.getAccessToken().getTokenValue(); + } + + /** + * FCM 전송 정보를 기반으로 메시지를 구성합니다. (Object -> String) + * + * @param fcmSendDTO, 모바일에서 전달받은 Object + * @return String + */ + private String makeMessage(FcmSendDTO fcmSendDTO) throws JsonProcessingException { + + ObjectMapper om = new ObjectMapper(); + FcmMessageDTO fcmMessageDto = FcmMessageDTO.builder() + .message(FcmMessageDTO.Message.builder() + .token(fcmSendDTO.getToken()) + .notification(FcmMessageDTO.Notification.builder() + .title(fcmSendDTO.getTitle()) + .body(fcmSendDTO.getBody()) + .image(null) + .build() + ).build()).validateOnly(false).build(); + + return om.writeValueAsString(fcmMessageDto); + } + + /** + * 클라이언트로부터 Device Token을 수신하여 저장합니다. + */ + @Transactional + public void registerDeviceToken(String userId, String token, DeviceType deviceType) { + Member member = memberRepository.findByUserIdAndStatusNot(userId, UserStatus.DELETED) + .orElseThrow(() -> new IllegalArgumentException("Member not found")); + + deviceTokenRepository.findByDeviceToken(token) + .orElseGet(() -> { + DeviceToken deviceToken = DeviceToken.builder() + .member(member) + .deviceToken(token) + .deviceType(deviceType) + .createdAt(LocalDateTime.now()) + .build(); + return deviceTokenRepository.save(deviceToken); + }); + } } diff --git a/src/main/java/com/cmc/suppin/fcm/service/FcmServiceImpl.java b/src/main/java/com/cmc/suppin/fcm/service/FcmServiceImpl.java deleted file mode 100644 index 0edde51..0000000 --- a/src/main/java/com/cmc/suppin/fcm/service/FcmServiceImpl.java +++ /dev/null @@ -1,96 +0,0 @@ -package com.cmc.suppin.fcm.service; - -import com.cmc.suppin.fcm.controller.dto.FcmMessageDTO; -import com.cmc.suppin.fcm.controller.dto.FcmSendDTO; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.google.auth.oauth2.GoogleCredentials; -import lombok.extern.slf4j.Slf4j; -import org.springframework.core.io.ClassPathResource; -import org.springframework.http.*; -import org.springframework.http.converter.StringHttpMessageConverter; -import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; - -import java.io.IOException; -import java.nio.charset.StandardCharsets; -import java.util.List; - -@Service -@Slf4j -public class FcmServiceImpl implements FcmService { - - /** - * 푸시 메시지 처리를 수행하는 비즈니스 로직 - * - * @param fcmSendDTO 모바일에서 전달받은 Object - * @return 성공(1), 실패(0) - */ - @Override - public int sendMessageTo(FcmSendDTO fcmSendDTO) throws IOException { - - try { - String message = makeMessage(fcmSendDTO); - RestTemplate restTemplate = new RestTemplate(); - - restTemplate.getMessageConverters().add(0, new StringHttpMessageConverter(StandardCharsets.UTF_8)); - - HttpHeaders headers = new HttpHeaders(); - headers.setContentType(MediaType.APPLICATION_JSON); - headers.set("Authorization", "Bearer " + getAccessToken()); - - HttpEntity entity = new HttpEntity<>(message, headers); - - String API_URL = "https://fcm.googleapis.com/v1/projects/suppin-a5657/messages:send"; - ResponseEntity response = restTemplate.exchange(API_URL, HttpMethod.POST, entity, String.class); - - if (response.getStatusCode() == HttpStatus.OK) { - return 1; - } else { - log.error("FCM 메시지 전송 실패: {}", response.getStatusCode()); - return 0; - } - } catch (Exception e) { - log.error("FCM 메시지 전송 중 예외 발생", e); - return 0; - } - } - - /** - * Firebase Admin SDK의 비공개 키를 참조하여 Bearer 토큰을 발급 받습니다. - * - * @return Bearer token, String - */ - private String getAccessToken() throws IOException { - String firebaseConfigPath = "firebase/suppin-a5657-firebase-adminsdk.json"; - - GoogleCredentials googleCredentials = GoogleCredentials - .fromStream(new ClassPathResource(firebaseConfigPath).getInputStream()) - .createScoped(List.of("")); - - googleCredentials.refreshIfExpired(); - return googleCredentials.getAccessToken().getTokenValue(); - } - - /** - * FCM 전송 정보를 기반으로 메시지를 구성합니다. (Object -> String) - * - * @param fcmSendDTO, 모바일에서 전달받은 Object - * @return String - */ - private String makeMessage(FcmSendDTO fcmSendDTO) throws JsonProcessingException { - - ObjectMapper om = new ObjectMapper(); - FcmMessageDTO fcmMessageDto = FcmMessageDTO.builder() - .message(FcmMessageDTO.Message.builder() - .token(fcmSendDTO.getToken()) - .notification(FcmMessageDTO.Notification.builder() - .title(fcmSendDTO.getTitle()) - .body(fcmSendDTO.getBody()) - .image(null) - .build() - ).build()).validateOnly(false).build(); - - return om.writeValueAsString(fcmMessageDto); - } -} diff --git a/src/main/java/com/cmc/suppin/fcm/service/PushNotificationScheduler.java b/src/main/java/com/cmc/suppin/fcm/service/PushNotificationScheduler.java new file mode 100644 index 0000000..a931c91 --- /dev/null +++ b/src/main/java/com/cmc/suppin/fcm/service/PushNotificationScheduler.java @@ -0,0 +1,42 @@ +package com.cmc.suppin.fcm.service; + +import com.cmc.suppin.event.events.domain.Event; +import com.cmc.suppin.event.events.domain.repository.EventRepository; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +public class PushNotificationScheduler { + + private final EventRepository eventRepository; + private final PushNotificationService pushNotificationService; + + public PushNotificationScheduler(EventRepository eventRepository, PushNotificationService pushNotificationService) { + this.eventRepository = eventRepository; + this.pushNotificationService = pushNotificationService; + } + + @Scheduled(cron = "0 0 * * * *") // 매 정시에 실행 + public void checkEventEndDates() { + List events = eventRepository.findAll(); // 모든 이벤트 조회 + + LocalDateTime now = LocalDateTime.now(); + + for (Event event : events) { + if (event.getEndDate() != null && event.getEndDate().isBefore(now)) { + // 이벤트 종료일이 지났을 때 + pushNotificationService.sendEventEndNotification(event); + } + + if (event.getAnnouncementDate() != null && event.getAnnouncementDate().isBefore(now)) { + // 당첨자 발표일이 지났을 때 + pushNotificationService.sendAnnouncementNotification(event); + } + } + } +} + + diff --git a/src/main/java/com/cmc/suppin/fcm/service/PushNotificationService.java b/src/main/java/com/cmc/suppin/fcm/service/PushNotificationService.java new file mode 100644 index 0000000..9ff1698 --- /dev/null +++ b/src/main/java/com/cmc/suppin/fcm/service/PushNotificationService.java @@ -0,0 +1,67 @@ +package com.cmc.suppin.fcm.service; + +import com.cmc.suppin.event.events.domain.Event; +import com.cmc.suppin.fcm.domain.DeviceToken; +import com.cmc.suppin.fcm.domain.respository.DeviceTokenRepository; +import com.google.firebase.messaging.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class PushNotificationService { + + private final DeviceTokenRepository deviceTokenRepository; + + public void sendEventEndNotification(Event event) { + // 이벤트 종료 알림 전송 로직 구현 + String message = "[ " + event.getTitle() + " ] 의 응답을 확인하시고 당첨자를 선정해 주세요."; + sendPushNotification(event, message); + } + + public void sendAnnouncementNotification(Event event) { + // 당첨자 발표일 알림 전송 로직 구현 + String message = "[ " + event.getTitle() + " ] 의 당첨자 발표일입니다."; + sendPushNotification(event, message); + } + + public void sendPushNotification(Event event, String message) { + List deviceTokens = deviceTokenRepository.findAllByMember(event.getMember()); + + List tokenStrings = deviceTokens.stream() + .map(DeviceToken::getDeviceToken) + .collect(Collectors.toList()); + + if (!tokenStrings.isEmpty()) { + Notification notification = Notification.builder() + .setTitle(event.getTitle()) + .setBody(message) + .build(); + + MulticastMessage notificationMessage = MulticastMessage.builder() + .setNotification(notification) + .putData("message", message) + .addAllTokens(tokenStrings) + .build(); + + try { + BatchResponse response = FirebaseMessaging.getInstance().sendMulticast(notificationMessage); + System.out.println("Successfully sent message: " + response.getSuccessCount() + " messages sent successfully"); + } catch (FirebaseMessagingException e) { + e.printStackTrace(); + System.err.println("Error sending message: " + e.getMessage()); + } + } + } + + private List getDeviceTokensForEvent(Event event) { + // 이벤트에 참여한 사용자들의 디바이스 토큰을 가져오는 로직 + return new ArrayList<>(); // 예시로 빈 리스트 반환 + } +} + + diff --git a/src/main/java/com/cmc/suppin/global/config/MailConfig.java b/src/main/java/com/cmc/suppin/global/config/MailConfig.java index d82a995..d8ed64d 100644 --- a/src/main/java/com/cmc/suppin/global/config/MailConfig.java +++ b/src/main/java/com/cmc/suppin/global/config/MailConfig.java @@ -29,7 +29,7 @@ public boolean sendMail(String toEmail, String code) { // Format the current date and time String formattedDateTime = ZonedDateTime.now(ZoneId.of("Asia/Seoul")) .format(DateTimeFormatter.ofPattern("yyyy-MM-dd, HH:mm")); - + // Use StringBuilder to construct the HTML email body StringBuilder emailBody = new StringBuilder(); emailBody.append("") @@ -42,13 +42,13 @@ public boolean sendMail(String toEmail, String code) { .append("") .append("
") .append("
") - .append("\"Suppin") + .append("\"Suppin") .append("

[Suppin] 인증번호를 안내해 드립니다.

") .append("
") .append("
") - .append("

안녕하세요, Suppin을 이용해주셔서 감사합니다 :)

") - .append("

Suppin 회원가입을 위해 인증번호를 안내해 드립니다. 아래 인증번호를 입력하여 이메일 인증을 완료해 주세요.

") - .append("
") + .append("

안녕하세요, Suppin을 이용해주셔서 감사합니다 :)

") + .append("

Suppin 회원가입을 위해 인증번호 안내 드립니다. 아래 인증번호를 5분 이내로 입력하여 이메일 인증을 완료해 주세요.

") + .append("
") // 인증번호 색상 .append(code) .append("
") .append("") @@ -73,7 +73,7 @@ public boolean sendMail(String toEmail, String code) { helper.setText(emailBody.toString(), true); // Add inline image - ClassPathResource logoImage = new ClassPathResource("static/images/suppin-logo.png"); + ClassPathResource logoImage = new ClassPathResource("static/images/suppin-logo2.png"); helper.addInline("suppinLogo", logoImage); javaMailSender.send(message); diff --git a/src/main/java/com/cmc/suppin/global/config/WebConfig.java b/src/main/java/com/cmc/suppin/global/config/WebConfig.java deleted file mode 100644 index 5f00f39..0000000 --- a/src/main/java/com/cmc/suppin/global/config/WebConfig.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.cmc.suppin.global.config; - -import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -import java.util.Arrays; - - -@RequiredArgsConstructor -@Configuration -public class WebConfig implements WebMvcConfigurer { - - - @Bean - public UrlBasedCorsConfigurationSource corsConfigurationSource() { - UrlBasedCorsConfigurationSource corsConfigSource = new UrlBasedCorsConfigurationSource(); - - - CorsConfiguration configuration = new CorsConfiguration(); - - configuration.addAllowedOriginPattern("*"); - configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); - configuration.setAllowedHeaders(Arrays.asList("*")); - configuration.setAllowCredentials(true); - configuration.setMaxAge(3600L); - - corsConfigSource.registerCorsConfiguration("/**", configuration); - return corsConfigSource; - } -} diff --git a/src/main/java/com/cmc/suppin/global/security/config/WebMvcConfig.java b/src/main/java/com/cmc/suppin/global/security/config/WebMvcConfig.java index 73ea984..3147c1d 100644 --- a/src/main/java/com/cmc/suppin/global/security/config/WebMvcConfig.java +++ b/src/main/java/com/cmc/suppin/global/security/config/WebMvcConfig.java @@ -38,8 +38,10 @@ private String[] getAllowOrigins() { "https://dev.suppin.store", "https://api.suppin.store", "https://suppin.store", - "http://192.168.200.120:3000", // 테스트 디바이스 IP 허용 - "https://coherent-midge-probably.ngrok-free.app" + "http://192.168.200.120:3000", + "https://coherent-midge-probably.ngrok-free.app", + "https://suppin-web.vercel.app/", + "https://suppin-survey.vercel.app" ).toArray(String[]::new); } } diff --git a/src/main/java/com/cmc/suppin/global/security/config/WebSecurityConfig.java b/src/main/java/com/cmc/suppin/global/security/config/WebSecurityConfig.java index fcee56a..79f85d3 100644 --- a/src/main/java/com/cmc/suppin/global/security/config/WebSecurityConfig.java +++ b/src/main/java/com/cmc/suppin/global/security/config/WebSecurityConfig.java @@ -115,6 +115,7 @@ private RequestMatcher[] requestHasRoleUser() { private RequestMatcher[] requestPermitAll() { List requestMatchers = List.of( antMatcher("/"), + antMatcher("/health"), antMatcher("/swagger-ui/**"), antMatcher("/actuator/**"), antMatcher("/v3/api-docs/**"), @@ -122,6 +123,7 @@ private RequestMatcher[] requestPermitAll() { antMatcher("/api/v1/members/join/**"), antMatcher("/api/v1/members/checkUserId"), antMatcher("/api/v1/members/checkEmail"), + antMatcher("/api/v1/survey/view/**"), antMatcher("/api/v1/survey/reply/**") // 설문조사 응답 시 적용 ); return requestMatchers.toArray(RequestMatcher[]::new); diff --git a/src/main/java/com/cmc/suppin/member/domain/Member.java b/src/main/java/com/cmc/suppin/member/domain/Member.java index 8fd23dd..baf169a 100644 --- a/src/main/java/com/cmc/suppin/member/domain/Member.java +++ b/src/main/java/com/cmc/suppin/member/domain/Member.java @@ -1,6 +1,7 @@ package com.cmc.suppin.member.domain; import com.cmc.suppin.event.events.domain.Event; +import com.cmc.suppin.fcm.domain.DeviceToken; import com.cmc.suppin.global.domain.BaseDateTimeEntity; import com.cmc.suppin.global.enums.UserRole; import com.cmc.suppin.global.enums.UserStatus; @@ -25,8 +26,13 @@ public class Member extends BaseDateTimeEntity { private Long id; @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @Builder.Default private List eventList = new ArrayList<>(); + @OneToMany(mappedBy = "member", cascade = CascadeType.ALL) + @Builder.Default + private List deviceTokenList = new ArrayList<>(); + @Column(columnDefinition = "VARCHAR(30)", nullable = false) private String userId; @@ -41,7 +47,7 @@ public class Member extends BaseDateTimeEntity { @Column(columnDefinition = "VARCHAR(13)", nullable = false) private String phoneNumber; - + @Enumerated(EnumType.STRING) private UserRole role; @@ -70,5 +76,15 @@ public void delete() { public void updatePassword(String encode) { this.password = encode; } + + public void addDeviceToken(DeviceToken deviceToken) { + deviceToken.setMember(this); + this.deviceTokenList.add(deviceToken); + } + + public void removeDeviceToken(DeviceToken deviceToken) { + this.deviceTokenList.remove(deviceToken); + deviceToken.setMember(null); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 86f9082..7d322b7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,6 +1,7 @@ spring: server: port: 8080 + connection-timeout: 15m jackson: time-zone: Asia/Seoul datasource: @@ -38,6 +39,9 @@ spring: protocol: smtp default-encoding: UTF-8 test-connection: false + mvc: + async: + request-timeout: 15m jwt: token: diff --git a/src/main/resources/drivers/chromedriver b/src/main/resources/drivers/chromedriver deleted file mode 100755 index 03fea3d..0000000 Binary files a/src/main/resources/drivers/chromedriver and /dev/null differ diff --git a/src/main/resources/static/images/suppin-logo2.png b/src/main/resources/static/images/suppin-logo2.png new file mode 100644 index 0000000..4b8c152 Binary files /dev/null and b/src/main/resources/static/images/suppin-logo2.png differ diff --git a/src/test/java/com/cmc/suppin/SuppinApplicationTests.java b/src/test/java/com/cmc/suppin/SuppinApplicationTests.java new file mode 100644 index 0000000..0e18457 --- /dev/null +++ b/src/test/java/com/cmc/suppin/SuppinApplicationTests.java @@ -0,0 +1,13 @@ +package com.cmc.suppin; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class SuppinApplicationTests { + + @Test + void contextLoads() { + } + +}