Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/33: FCM 푸시알림 기능 구현-1 #34

Merged
merged 9 commits into from
Aug 11, 2024
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
Loading