Skip to content

Commit

Permalink
Merge pull request #34 from Central-MakeUs/feature/33
Browse files Browse the repository at this point in the history
Feature/33: FCM 푸시알림 기능 구현-1
  • Loading branch information
yxhwxn authored Aug 11, 2024
2 parents 6a3a04f + c0359b3 commit e2372a3
Show file tree
Hide file tree
Showing 15 changed files with 278 additions and 29 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
/src/main/resources/firebase/
/src/main/resources/firebase/suppin-a5657-firebase-adminsdk-s75m9-d65cc88029.json

### STS ###
.apt_generated
Expand Down
13 changes: 10 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,22 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.1.0'
implementation 'org.springframework.boot:spring-boot-starter-mail'

// JWT dependencies
//JWT dependencies
implementation 'io.jsonwebtoken:jjwt-api:0.12.2'
implementation 'io.jsonwebtoken:jjwt-impl:0.12.2'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.2'

implementation 'org.springframework.boot:spring-boot-starter-mail'

//selenium
implementation 'org.seleniumhq.selenium:selenium-java:4.1.4'
implementation 'io.github.bonigarcia:webdrivermanager:5.0.3'
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'

compileOnly 'org.projectlombok:lombok'
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'com.mysql:mysql-connector-j'
Expand All @@ -61,3 +63,8 @@ tasks.named('test') {
jar {
enabled = false
}

tasks.withType(JavaCompile) {
options.compilerArgs << "-parameters"
}

Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,12 @@ public class CommentApi {
@Operation(summary = "크롤링된 전체 댓글 조회 API",
description = "주어진 이벤트 ID와 URL의 댓글을 페이지네이션하여 이벤트의 endDate 전에 작성된 댓글들만 조회합니다. 자세한 요청 및 응답 형식은 노션 API 문서를 참고해주세요.")
public ResponseEntity<ApiResponse<CommentResponseDTO.CrawledCommentListDTO>> getComments(
@RequestParam Long eventId,
@RequestParam String url,
@RequestParam("eventId") Long eventId,
@RequestParam("url") String url,
@Parameter(description = "조회할 페이지 번호 (1부터 시작)")
@RequestParam int page,
@RequestParam("page") int page,
@Parameter(description = "한 페이지당 댓글 수")
@RequestParam int size,
@RequestParam("size") int size,
@CurrentAccount Account account) {
CommentResponseDTO.CrawledCommentListDTO comments = commentService.getComments(eventId, url, page, size, account.userId());
return ResponseEntity.ok(ApiResponse.of(comments));
Expand All @@ -55,8 +55,8 @@ public ResponseEntity<ApiResponse<CommentResponseDTO.WinnerResponseDTO>> drawWin
@GetMapping("/winners/keywordFiltering")
@Operation(summary = "키워드별 당첨자 조회 API", description = "주어진 키워드에 따라 1차 랜덤 추첨된 당첨자 중에서 키워드가 포함된 당첨자들을 조회합니다.")
public ResponseEntity<ApiResponse<List<CommentResponseDTO.CommentDetailDTO>>> getWinnersByKeyword(
@RequestParam Long eventId,
@RequestParam String keyword,
@RequestParam("eventId") Long eventId,
@RequestParam("keyword") String keyword,
@CurrentAccount Account account) {
List<CommentResponseDTO.CommentDetailDTO> filteredWinners = commentService.getCommentsByKeyword(eventId, keyword, account.userId());
return ResponseEntity.ok(ApiResponse.of(filteredWinners));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,11 @@ public class CrawlApi {
@Operation(summary = "크롤링 중복 검증 API",
description = "주어진 URL과 eventId로 중복된 댓글 수집 이력이 있는지 확인합니다.<br><br>" +
"Request<br>" +
"- url: 중복 검증할 URL<br>" +
"- eventId: 댓글 이벤트 생성 후 입력 받은 eventId<br><br>" +
"- url: 중복 검증할 URL<br><br>" +
"Response<br>" +
"- 요청된 URL과 중복된 댓글 수집 이력이 있을 경우 '검증 및 확인되었습니다.' 출력<br>" +
"- 요청된 URL과 중복된 댓글 수집 이력이 없을 경우 '수집 이력이 없습니다.' 출력")
public ResponseEntity<ApiResponse<String>> checkExistingComments(@RequestParam String url, @CurrentAccount Account account) {
public ResponseEntity<ApiResponse<String>> checkExistingComments(@RequestParam("url") String url, @CurrentAccount Account account) {
String message = crawlService.checkExistingComments(url, account.userId());
if (message != null) {
return ResponseEntity.ok(ApiResponse.of(ResponseCode.SUCCESS, message));
Expand All @@ -47,11 +46,12 @@ public ResponseEntity<ApiResponse<String>> checkExistingComments(@RequestParam S
description = "주어진 URL의 유튜브 댓글을 크롤링하여 해당 댓글 데이터를 DB에 저장합니다.<br><br>" +
"Request: url: 크롤링할 URL, eventId: 댓글을 수집할 eventId, forceUpdate: 댓글을 강제로 업데이트할지 여부(Boolean), Authorization: JWT 토큰을 포함한 인증 헤더 <br><br>" +
"<forceUpdate 입력 값이 true일 때> <br> " +
"- 동일한 URL에 대한 댓글 크롤링 요청이지만, 강제로 업데이트하겠다는 의미이기 때문에, 기존 댓글 데이터를 삭제하고 새로 등록합니다. <br><br>" +
"- 동일한 URL에 대한 댓글 크롤링 요청이지만 강제로 업데이트하겠다는 의미이기 때문에, 기존 댓글 데이터를 삭제하고 새로 등록합니다. <br><br>" +
"<forceUpdate 입력 값이 false일 때> <br> " +
"크롤링하려는 URL이 중복되지 않았을 때의 요청이기 때문에, 새로운 댓글을 크롤링합니다. <br>" +
"- DB에 기존 댓글이 존재하는 경우: 크롤링을 중지하고 예외를 던집니다. <br>" +
"- DB에 기존 댓글이 존재하지 않는 경우: 새로운 댓글을 크롤링하고 이를 DB에 저장합니다.")
public ResponseEntity<ApiResponse<String>> crawlYoutubeComments(@RequestParam String url, @RequestParam Long eventId, @RequestParam boolean forceUpdate, @CurrentAccount Account account) {
public ResponseEntity<ApiResponse<String>> 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, "댓글 수집이 완료되었습니다."));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,13 @@ public void crawlYoutubeComments(String url, Long eventId, String userId, boolea
}
}

// 크롤링 코드 실행 (생략)
System.setProperty("webdriver.chrome.driver", "src/main/resources/drivers/chromedriver");
// 크롤링 코드 실행
String chromeDriverPath = System.getenv("CHROME_DRIVER_PATH");
if (chromeDriverPath != null && !chromeDriverPath.isEmpty()) {
System.setProperty("webdriver.chrome.driver", chromeDriverPath);
} else {
throw new RuntimeException("CHROME_DRIVER_PATH 환경 변수가 설정되지 않았습니다.");
}

ChromeOptions options = new ChromeOptions();
options.addArguments("--headless");
Expand All @@ -95,7 +100,7 @@ public void crawlYoutubeComments(String url, Long eventId, String userId, boolea
try {
Thread.sleep(5000); // 초기 로딩 대기

long endTime = System.currentTimeMillis() + 240000; // 스크롤 시간 조정 (필요에 따라 조정)
long endTime = System.currentTimeMillis() + 300000; // 스크롤 시간 조정 (필요에 따라 조정)
JavascriptExecutor jsExecutor = (JavascriptExecutor) driver;

while (System.currentTimeMillis() < endTime) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ public ResponseEntity<ApiResponse<Void>> updateEvent(@PathVariable Long eventId,

@DeleteMapping("/{eventId}")
@Operation(summary = "이벤트 삭제 API", description = "PathVariable: eventId, JWT 토큰만 주시면 됩니다.")
public ResponseEntity<ApiResponse<Void>> deleteEvent(@PathVariable Long eventId, @CurrentAccount Account account) {
public ResponseEntity<ApiResponse<Void>> deleteEvent(@PathVariable("eventId") Long eventId, @CurrentAccount Account account) {
eventService.deleteEvent(eventId, account.userId());
return ResponseEntity.ok(ApiResponse.of(ResponseCode.SUCCESS));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ public class ScheduledTasks {

private final EventService eventService;

@Scheduled(cron = "0 0 0 * * *") // 매일 자정에 실행
@Scheduled(cron = "0 0 * * * *") // 매 시간마다 실행
public void updateEventStatuses() {
eventService.updateEventStatus();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ public ResponseEntity<ApiResponse<SurveyResponseDTO.SurveyCreateResponse>> creat

@GetMapping("/{surveyId}")
@Operation(summary = "설문지 조회 API", description = "생성된 설문지 전체 정보를 조회합니다. 자세한 요청 및 응답 형식은 노션 API 문서를 참고해주세요.")
public ResponseEntity<ApiResponse<SurveyResponseDTO.SurveyResultDTO>> getSurvey(@PathVariable Long surveyId) {
public ResponseEntity<ApiResponse<SurveyResponseDTO.SurveyResultDTO>> getSurvey(@PathVariable("surveyId") Long surveyId) {
SurveyResponseDTO.SurveyResultDTO response = surveyService.getSurvey(surveyId);
return ResponseEntity.ok(ApiResponse.of(response));
}
Expand All @@ -51,12 +51,12 @@ public ResponseEntity<ApiResponse<Void>> saveSurveyAnswers(@RequestBody @Valid S
@GetMapping("/{surveyId}/answers/{questionId}")
@Operation(summary = "질문별 설문 응답 결과 조회 API", description = "특정 질문에 따라 해당 질문에 대한 설문 결과를 응답합니다. 자세한 요청 및 응답 형식은 노션 API 문서를 참고해주세요.")
public ResponseEntity<ApiResponse<SurveyResponseDTO.SurveyAnswerResultDTO>> getSurveyAnswers(
@PathVariable Long surveyId,
@PathVariable Long questionId,
@PathVariable("surveyId") Long surveyId,
@PathVariable("questionId") Long questionId,
@Parameter(description = "페이지 번호(1부터 시작)", example = "1")
@RequestParam int page,
@RequestParam("page") int page,
@Parameter(description = "페이지 크기", example = "10")
@RequestParam int size,
@RequestParam("size") int size,
@CurrentAccount Account account) {
SurveyResponseDTO.SurveyAnswerResultDTO response = surveyService.getSurveyAnswers(surveyId, questionId, page, size, account.userId());
return ResponseEntity.ok(ApiResponse.of(response));
Expand All @@ -75,14 +75,14 @@ public ResponseEntity<ApiResponse<SurveyResponseDTO.RandomSelectionResponseDTO>>
@GetMapping("/winners/{surveyId}/{participantId}")
@Operation(summary = "당첨자 세부 정보 조회 API", description = "설문 이벤트의 당첨자(익명 참여자) 정보를 조회하며, 해당 참여자가 응답한 모든 설문 내용을 반환합니다. 자세한 요청 및 응답 형식은 노션 API 문서를 참고해주세요.")
public ResponseEntity<ApiResponse<SurveyResponseDTO.WinnerDetailDTO>> getWinnerDetails(
@PathVariable Long surveyId, @PathVariable Long participantId) {
@PathVariable("surveyId") Long surveyId, @PathVariable("participantId") Long participantId) {
SurveyResponseDTO.WinnerDetailDTO winnerDetails = surveyService.getWinnerDetails(surveyId, participantId);
return ResponseEntity.ok(ApiResponse.of(winnerDetails));
}

@DeleteMapping("/winners")
@Operation(summary = "당첨자 리스트 삭제 API(당첨자 재추첨 시, 기존 당첨자 리스트를 삭제 후 진행 해야합니다.)", description = "해당 설문조사의 모든 당첨자들의 isWinner 값을 false로 변경합니다.")
public ResponseEntity<ApiResponse<Void>> deleteWinners(@RequestParam Long surveyId) {
public ResponseEntity<ApiResponse<Void>> deleteWinners(@RequestParam("surveyId") Long surveyId) {
surveyService.deleteWinners(surveyId);
return ResponseEntity.ok(ApiResponse.of(ResponseCode.SUCCESS));
}
Expand Down
34 changes: 34 additions & 0 deletions src/main/java/com/cmc/suppin/fcm/config/FirebaseConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.cmc.suppin.fcm.config;

import com.google.auth.oauth2.GoogleCredentials;
import com.google.firebase.FirebaseApp;
import com.google.firebase.FirebaseOptions;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;

import javax.annotation.PostConstruct;
import java.io.FileInputStream;
import java.io.InputStream;

@Configuration
public class FirebaseConfig {

@Value("${FIREBASE_CONFIG_PATH}")
private String firebaseConfigPath;

@PostConstruct
public void init() {
try {
InputStream serviceAccount = new FileInputStream(firebaseConfigPath);
FirebaseOptions options = new FirebaseOptions.Builder()
.setCredentials(GoogleCredentials.fromStream(serviceAccount))
.build();

if (FirebaseApp.getApps().isEmpty()) { // FirebaseApp이 이미 초기화되어 있지 않은 경우에만 초기화 실행
FirebaseApp.initializeApp(options);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
37 changes: 37 additions & 0 deletions src/main/java/com/cmc/suppin/fcm/controller/FcmController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.cmc.suppin.fcm.controller;

import com.cmc.suppin.fcm.controller.dto.FcmSendDTO;
import com.cmc.suppin.fcm.service.FcmService;
import com.cmc.suppin.global.response.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.io.IOException;

@Slf4j
@RestController
@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<ApiResponse<Object>> pushMessage(@RequestBody @Validated FcmSendDTO fcmSendDto) throws IOException {
log.debug("[+] 푸시 메시지를 전송합니다. ");
int result = fcmService.sendMessageTo(fcmSendDto);

return ResponseEntity.ok(ApiResponse.of(result));
}
}
32 changes: 32 additions & 0 deletions src/main/java/com/cmc/suppin/fcm/controller/dto/FcmMessageDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.cmc.suppin.fcm.controller.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

/**
* FCM에 실제 전송될 데이터의 DTO
*/
@Getter
@Builder
public class FcmMessageDTO {
private boolean validateOnly;
private FcmMessageDTO.Message message;

@Builder
@AllArgsConstructor
@Getter
public static class Message {
private FcmMessageDTO.Notification notification;
private String token;
}

@Builder
@AllArgsConstructor
@Getter
public static class Notification {
private String title;
private String body;
private String image;
}
}
24 changes: 24 additions & 0 deletions src/main/java/com/cmc/suppin/fcm/controller/dto/FcmSendDTO.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.cmc.suppin.fcm.controller.dto;

import lombok.*;

/**
* 모바일에서 전달받은 객체를 FCM으로 전송하기 위한 DTO
*/
@Getter
@ToString
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class FcmSendDTO {
private String token;

private String title;

private String body;

@Builder(toBuilder = true)
public FcmSendDTO(String token, String title, String body) {
this.token = token;
this.title = title;
this.body = body;
}
}
12 changes: 12 additions & 0 deletions src/main/java/com/cmc/suppin/fcm/service/FcmService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.cmc.suppin.fcm.service;

import com.cmc.suppin.fcm.controller.dto.FcmSendDTO;
import org.springframework.stereotype.Service;

import java.io.IOException;

@Service
public interface FcmService {

int sendMessageTo(FcmSendDTO fcmSendDTO) throws IOException;
}
Loading

0 comments on commit e2372a3

Please sign in to comment.