diff --git a/build.gradle b/build.gradle index 57b2d82..04255d2 100644 --- a/build.gradle +++ b/build.gradle @@ -60,9 +60,6 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - - // Firebase Admin SDK: 서버 측에서 FCM, 인증 등을 제어할 수 있게 해주는 라이브러리 - implementation 'com.google.firebase:firebase-admin:9.2.0' } tasks.named('test') { diff --git a/src/main/java/com/moongeul/backend/api/member/service/FollowService.java b/src/main/java/com/moongeul/backend/api/member/service/FollowService.java index 2821618..4d875ea 100644 --- a/src/main/java/com/moongeul/backend/api/member/service/FollowService.java +++ b/src/main/java/com/moongeul/backend/api/member/service/FollowService.java @@ -8,6 +8,7 @@ import com.moongeul.backend.api.member.entity.PrivacyLevel; import com.moongeul.backend.api.member.repository.FollowRepository; import com.moongeul.backend.api.member.repository.MemberRepository; +import com.moongeul.backend.api.notification.service.NotificationTriggerService; import com.moongeul.backend.common.exception.BadRequestException; import com.moongeul.backend.common.exception.NotFoundException; import com.moongeul.backend.common.response.ErrorStatus; @@ -28,6 +29,8 @@ public class FollowService { private final FollowRepository followRepository; private final MemberRepository memberRepository; + private final NotificationTriggerService notificationTriggerService; + /* 팔로우 API */ @Transactional public void follow(Long following_id, String email){ @@ -59,6 +62,10 @@ public void follow(Long following_id, String email){ .build(); followRepository.save(newFollow); + + log.info("팔로우 완료 - 팔로우 대상 ID: {}, 작성자 ID: {}", following.getNickname(), follower.getNickname()); + + notificationTriggerService.followNotification(following, follower); // 팔로우 알림 발생 } /* 언팔로우 API - 이 경우, 승인 대기중 상태도 같이 삭제 */ @@ -71,6 +78,8 @@ public void unfollow(Long followingId, String email) { .orElseThrow(() -> new BadRequestException(ErrorStatus.NO_FOLLOW_RELATIONSHIP.getMessage())); followRepository.delete(follow); + + log.info("언팔로우 완료 - 언팔로우 대상 ID: {}, 작성자 ID: {}", follow.getFollowing().getNickname(), follower.getNickname()); } // 팔로잉 사용자 목록 조회 diff --git a/src/main/java/com/moongeul/backend/api/notification/dto/DeviceTokenRequestDTO.java b/src/main/java/com/moongeul/backend/api/notification/dto/DeviceTokenRequestDTO.java index 8b1acbe..cf287d2 100644 --- a/src/main/java/com/moongeul/backend/api/notification/dto/DeviceTokenRequestDTO.java +++ b/src/main/java/com/moongeul/backend/api/notification/dto/DeviceTokenRequestDTO.java @@ -12,5 +12,5 @@ public class DeviceTokenRequestDTO { private String token; - private String platform; // 'ANDROID' or 'IOS' or 'WEB' + private String platform; // AND, IOS, WEB } diff --git a/src/main/java/com/moongeul/backend/api/notification/dto/ExpoPushRequestDTO.java b/src/main/java/com/moongeul/backend/api/notification/dto/ExpoPushRequestDTO.java new file mode 100644 index 0000000..fe0c366 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/notification/dto/ExpoPushRequestDTO.java @@ -0,0 +1,22 @@ +package com.moongeul.backend.api.notification.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ExpoPushRequestDTO { + + private List to; // 수신자 토큰 리스트 + private String title; // 알림 제목 + private String body; // 알림 본문 + private String sound; // "default" + private Map pushData; // 추가 데이터 +} diff --git a/src/main/java/com/moongeul/backend/api/notification/event/AnswerNotificationEvent.java b/src/main/java/com/moongeul/backend/api/notification/event/AnswerNotificationEvent.java new file mode 100644 index 0000000..b90a1ae --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/notification/event/AnswerNotificationEvent.java @@ -0,0 +1,11 @@ +package com.moongeul.backend.api.notification.event; + +import com.moongeul.backend.api.member.entity.Member; +import com.moongeul.backend.api.question.entity.Question; + +public record AnswerNotificationEvent( + + Member receiver, // 알림을 받을 사람 (질문 작성자) + Member actor, // 댓글을 작성한 사람 + Question question // 어떤 질문인지 +) {} diff --git a/src/main/java/com/moongeul/backend/api/notification/event/FollowNotificationEvent.java b/src/main/java/com/moongeul/backend/api/notification/event/FollowNotificationEvent.java new file mode 100644 index 0000000..bb77eee --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/notification/event/FollowNotificationEvent.java @@ -0,0 +1,9 @@ +package com.moongeul.backend.api.notification.event; + +import com.moongeul.backend.api.member.entity.Member; + +public record FollowNotificationEvent( + + Member receiver, // 알림을 받을 사람 + Member actor // 팔로우를 건 사람 +) {} diff --git a/src/main/java/com/moongeul/backend/api/notification/listener/NotificationEventListener.java b/src/main/java/com/moongeul/backend/api/notification/listener/NotificationEventListener.java index e38e4b4..ca45a8f 100644 --- a/src/main/java/com/moongeul/backend/api/notification/listener/NotificationEventListener.java +++ b/src/main/java/com/moongeul/backend/api/notification/listener/NotificationEventListener.java @@ -1,6 +1,9 @@ package com.moongeul.backend.api.notification.listener; +import com.moongeul.backend.api.member.entity.PrivacyLevel; import com.moongeul.backend.api.notification.entity.NotificationType; +import com.moongeul.backend.api.notification.event.AnswerNotificationEvent; +import com.moongeul.backend.api.notification.event.FollowNotificationEvent; import com.moongeul.backend.api.notification.event.LikeNotificationEvent; import com.moongeul.backend.api.notification.service.NotificationService; import lombok.RequiredArgsConstructor; @@ -14,12 +17,13 @@ public class NotificationEventListener { // 발행된 이벤트를 받아서 별 private final NotificationService notificationService; + /* 공감 알림 */ @Async // 별도의 스레드에서 실행되도록 설정 @EventListener // LikeNotificationEvent가 발행되면 호출됨 public void handleLikeNotification(LikeNotificationEvent event) { String message = event.actor().getNickname() + "님이 회원님의 기록에 공감했습니다."; - // 실제 DB 저장 및 FCM 발송 로직 실행 + // 실제 DB 저장 및 Expo 푸시 알림 발송 로직 실행 notificationService.send( event.receiver(), event.actor(), @@ -28,4 +32,43 @@ public void handleLikeNotification(LikeNotificationEvent event) { event.post().getId() ); } + + /* 댓글 알림 */ + @Async + @EventListener + public void handleCommentNotification(AnswerNotificationEvent event) { + String message = event.actor().getNickname() + "님이 회원님의 질문에 댓글을 달았습니다."; + + // 실제 DB 저장 및 Expo 푸시 알림 발송 로직 실행 + notificationService.send( + event.receiver(), + event.actor(), + NotificationType.LIKE, + message, + event.question().getId() + ); + } + + /* 팔로우 알림 */ + @Async + @EventListener + public void handleFollowNotification(FollowNotificationEvent event) { + + String message = event.actor().getNickname() + "님이 회원님에게 팔로우를 요청했습니다."; + NotificationType notificationType = NotificationType.FOLLOW_PRIVATE; + + if(event.receiver().getPrivacyLevel() == PrivacyLevel.PUBLIC){ // 공개 계정일 경우 (요청x) + message = event.actor().getNickname() + "님이 회원님을 팔로우 했습니다."; + notificationType = NotificationType.FOLLOW_OPEN; + } + + // 실제 DB 저장 및 Expo 푸시 알림 발송 로직 실행 + notificationService.send( + event.receiver(), + event.actor(), + notificationType, + message, + event.actor().getId() + ); + } } diff --git a/src/main/java/com/moongeul/backend/api/notification/service/NotificationService.java b/src/main/java/com/moongeul/backend/api/notification/service/NotificationService.java index ef174a9..27cd8f5 100644 --- a/src/main/java/com/moongeul/backend/api/notification/service/NotificationService.java +++ b/src/main/java/com/moongeul/backend/api/notification/service/NotificationService.java @@ -1,7 +1,7 @@ package com.moongeul.backend.api.notification.service; -import com.google.firebase.messaging.*; import com.moongeul.backend.api.notification.dto.DeviceTokenRequestDTO; +import com.moongeul.backend.api.notification.dto.ExpoPushRequestDTO; import com.moongeul.backend.api.notification.entity.DeviceToken; import com.moongeul.backend.api.notification.entity.NotificationType; import com.moongeul.backend.api.notification.entity.Notifications; @@ -15,8 +15,12 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.reactive.function.client.WebClient; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; @Service @Slf4j @@ -27,25 +31,38 @@ public class NotificationService { private final MemberRepository memberRepository; private final NotificationRepository notificationRepository; + private final WebClient webClient; + private static final String EXPO_PUSH_URL = "https://exp.host/--/api/v2/push/send"; + /* 토큰 등록/수정 */ @Transactional - public void registerOrUpdateToken(String email, DeviceTokenRequestDTO requestDTO) { + public void registerOrUpdateToken(String email, DeviceTokenRequestDTO deviceTokenRequestDTO) { Member member = memberRepository.findByEmail(email) .orElseThrow(() -> new NotFoundException(ErrorStatus.USER_NOTFOUND_EXCEPTION.getMessage())); - deviceTokenRepository.findByToken(requestDTO.getToken()) + // 1. 기존에 해당 토큰이 있는지 확인 + deviceTokenRepository.findByToken(deviceTokenRequestDTO.getToken()) .ifPresentOrElse( - token -> token.updateMember(member), - () -> deviceTokenRepository.save(DeviceToken.builder() - .member(member) - .token(requestDTO.getToken()) - .platform(requestDTO.getPlatform()) - .build()) + // 2. 이미 있다면: 토큰의 주인이 바뀌었을 수 있으므로 업데이트 + existingToken -> { + existingToken.updateMember(member); + }, + // 3. 없다면: 새로운 DeviceToken 생성 및 저장 + () -> { + DeviceToken newToken = DeviceToken.builder() + .member(member) + .token(deviceTokenRequestDTO.getToken()) + .platform(deviceTokenRequestDTO.getPlatform()) + .build(); + deviceTokenRepository.save(newToken); + } ); } + /* 알림 전송 */ @Transactional public void send(Member receiver, Member actor, NotificationType notificationType, String message, Long relatedId) { + // 1. 알림 내역 DB 저장 Notifications notifications = Notifications.builder() .receiver(receiver) .actor(actor) @@ -56,37 +73,52 @@ public void send(Member receiver, Member actor, NotificationType notificationTyp .build(); notificationRepository.save(notifications); - List tokens = deviceTokenRepository.findAllByMemberId(receiver.getId()); + // 2. 수신자의 모든 디바이스 토큰 조회 + List tokenStrings = deviceTokenRepository.findAllByMemberId(receiver.getId()) + .stream() + .map(DeviceToken::getToken) + .collect(Collectors.toList()); + + // Expo 전송용 추가 데이터 구성 + Map pushData = new HashMap<>(); + pushData.put("type", notificationType); + pushData.put("id", relatedId); - if (!tokens.isEmpty()) { - for (DeviceToken deviceToken : tokens) { - try { - sendMessage(deviceToken.getToken(), notificationType.getKey(), message); - } catch (FirebaseMessagingException e) { - if (e.getMessagingErrorCode() == MessagingErrorCode.UNREGISTERED) { - log.warn("유효하지 않은 토큰 삭제: {}", deviceToken.getToken()); - deviceTokenRepository.delete(deviceToken); - } else { - log.error("FCM 에러 발생: {}", e.getMessage()); - } - } catch (Exception e) { - log.error("일반 전송 에러: {}", e.getMessage()); - } - } + if (!tokenStrings.isEmpty()) { + // 3. WebClient로 비동기 전송 + sendToExpo(tokenStrings, notificationType.getKey(), message, pushData); } } - /* 토큰을 가진 기기에 푸시 알림 전송 */ - public void sendMessage(String targetToken, String title, String body) throws FirebaseMessagingException { - Message message = Message.builder() - .setToken(targetToken) - .setNotification(Notification.builder() - .setTitle(title) // 알림 타입 - .setBody(body) // 알림 내용 - .build()) + /* Expo Push API 호출 */ + private void sendToExpo(List targetTokens, String title, String body, Map pushData) { + // 페이로드 구성 + ExpoPushRequestDTO requestPayload = ExpoPushRequestDTO.builder() + .to(targetTokens) + .title(title) + .body(body) + .sound("default") + .pushData(pushData) .build(); - String response = FirebaseMessaging.getInstance().send(message); // 여기서 발생하는 에러가 위쪽(send 메서드)으로 전달 - log.info("FCM 전송 성공: " + response); + webClient.post() + .uri(EXPO_PUSH_URL) + .bodyValue(requestPayload) + .retrieve() + .bodyToMono(Map.class) // 응답을 Map 형태로 받음 + .subscribe( + response -> log.info("Expo 푸시 전송 성공: {}", response), + error -> log.error("Expo 푸시 전송 실패: {}", error.getMessage()) + ); + } + + /* 로그아웃 시, 토큰 삭제 */ + @Transactional + public void removeDeviceToken(String token) { + // 토큰이 존재할 경우에만 삭제 진행 + deviceTokenRepository.findByToken(token).ifPresent(deviceToken -> { + deviceTokenRepository.delete(deviceToken); + log.info("로그아웃으로 인한 디바이스 토큰 삭제 완료: {}", token); + }); } } \ No newline at end of file diff --git a/src/main/java/com/moongeul/backend/api/notification/service/NotificationTriggerService.java b/src/main/java/com/moongeul/backend/api/notification/service/NotificationTriggerService.java new file mode 100644 index 0000000..d29c6df --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/notification/service/NotificationTriggerService.java @@ -0,0 +1,37 @@ +package com.moongeul.backend.api.notification.service; + +import com.moongeul.backend.api.member.entity.Member; +import com.moongeul.backend.api.notification.event.AnswerNotificationEvent; +import com.moongeul.backend.api.notification.event.FollowNotificationEvent; +import com.moongeul.backend.api.notification.event.LikeNotificationEvent; +import com.moongeul.backend.api.post.entity.Post; +import com.moongeul.backend.api.question.entity.Question; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +@RequiredArgsConstructor +public class NotificationTriggerService { + + private final ApplicationEventPublisher eventPublisher; // Spring Event 발행 객체 + + /* 공감 알림 */ + public void likeNotification(Member receiver, Member actor, Post post){ + if (!receiver.getId().equals(actor.getId())) { // 자신의 게시글일 경우 알림 발생 x + eventPublisher.publishEvent(new LikeNotificationEvent(receiver, actor, post)); + } + } + + /* 댓글 알림 */ + public void answerNotification(Member receiver, Member actor, Question question){ + eventPublisher.publishEvent(new AnswerNotificationEvent(receiver, actor, question)); + } + + /* 팔로우 알림 */ + public void followNotification(Member receiver, Member actor){ + eventPublisher.publishEvent(new FollowNotificationEvent(receiver, actor)); + } +} diff --git a/src/main/java/com/moongeul/backend/api/post/service/PostService.java b/src/main/java/com/moongeul/backend/api/post/service/PostService.java index da07ad3..b135096 100644 --- a/src/main/java/com/moongeul/backend/api/post/service/PostService.java +++ b/src/main/java/com/moongeul/backend/api/post/service/PostService.java @@ -1,6 +1,5 @@ package com.moongeul.backend.api.post.service; -import com.moongeul.backend.api.notification.event.LikeNotificationEvent; import com.moongeul.backend.api.book.entity.Book; import com.moongeul.backend.api.book.repository.BookRepository; import com.moongeul.backend.api.bookshelf.entity.DoneReadBookshelf; @@ -8,6 +7,7 @@ import com.moongeul.backend.api.bookshelf.util.BookshelfCalculator; import com.moongeul.backend.api.member.entity.Member; import com.moongeul.backend.api.member.repository.MemberRepository; +import com.moongeul.backend.api.notification.service.NotificationTriggerService; import com.moongeul.backend.api.post.dto.*; import com.moongeul.backend.api.category.entity.Category; import com.moongeul.backend.api.post.entity.*; @@ -20,7 +20,6 @@ import com.moongeul.backend.common.response.ErrorStatus; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -48,8 +47,9 @@ public class PostService { private final QuoteRepository quoteRepository; private final DoneReadBookshelfRepository doneReadBookshelfRepository; private final BookshelfCalculator bookshelfCalculator; + + private final NotificationTriggerService notificationTriggerService; - private final ApplicationEventPublisher eventPublisher; // Spring Event 발행 객체 /* * 기록 @@ -312,7 +312,7 @@ public void likePost(Long postId, String email, LikeDTO likeDTO){ currentLikes.changeLikeType(likeDTO.getLikeType()); incrementLikeCount(post, likeDTO.getLikeType()); - likeNotification(post.getMember(), member, post); // 알림 발생 + notificationTriggerService.likeNotification(post.getMember(), member, post); // 알림 발생 return; } @@ -320,13 +320,7 @@ public void likePost(Long postId, String email, LikeDTO likeDTO){ likeRepository.save(likeDTO.toEntity(member, post)); incrementLikeCount(post, likeDTO.getLikeType()); - likeNotification(post.getMember(), member, post); // 알림 발생 - } - - private void likeNotification(Member receiver, Member actor, Post post){ - if (!receiver.getId().equals(actor.getId())) { // 자신의 게시글일 경우 알림 발생 x - eventPublisher.publishEvent(new LikeNotificationEvent(receiver, actor, post)); - } + notificationTriggerService.likeNotification(post.getMember(), member, post); // 알림 발생 } // 공감 카운트 증가 메서드 diff --git a/src/main/java/com/moongeul/backend/api/question/service/AnswerService.java b/src/main/java/com/moongeul/backend/api/question/service/AnswerService.java index 21fe880..23f43fb 100644 --- a/src/main/java/com/moongeul/backend/api/question/service/AnswerService.java +++ b/src/main/java/com/moongeul/backend/api/question/service/AnswerService.java @@ -2,6 +2,7 @@ import com.moongeul.backend.api.member.entity.Member; import com.moongeul.backend.api.member.repository.MemberRepository; +import com.moongeul.backend.api.notification.service.NotificationTriggerService; import com.moongeul.backend.api.question.dto.AnswerCreateRequestDTO; import com.moongeul.backend.api.question.dto.AnswerDTO; import com.moongeul.backend.api.question.dto.AnswerIdResponseDTO; @@ -34,6 +35,8 @@ public class AnswerService { private final QuestionRepository questionRepository; private final AnswerRepository answerRepository; + private final NotificationTriggerService notificationTriggerService; + // 답변 생성 @Transactional public AnswerIdResponseDTO createAnswer(AnswerCreateRequestDTO answerCreateRequestDTO, String email) { @@ -56,6 +59,8 @@ public AnswerIdResponseDTO createAnswer(AnswerCreateRequestDTO answerCreateReque log.info("답변 생성 완료 - 답변 ID: {}, 질문 ID: {}, 작성자: {}, 현재 댓글 수: {}", savedAnswer.getId(), question.getId(), member.getEmail(), question.getCommentCnt()); + notificationTriggerService.answerNotification(question.getMember(), member, question); // 댓글 알림 발생 + return AnswerIdResponseDTO.builder() .answerId(savedAnswer.getId()) .build(); diff --git a/src/main/java/com/moongeul/backend/common/config/firebase/FirebaseConfig.java b/src/main/java/com/moongeul/backend/common/config/firebase/FirebaseConfig.java deleted file mode 100644 index 5042526..0000000 --- a/src/main/java/com/moongeul/backend/common/config/firebase/FirebaseConfig.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.moongeul.backend.common.config.firebase; - -import com.google.auth.oauth2.GoogleCredentials; -import com.google.firebase.FirebaseApp; -import com.google.firebase.FirebaseOptions; -import jakarta.annotation.PostConstruct; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Configuration; -import org.springframework.core.io.ClassPathResource; - -import java.io.IOException; -import java.io.InputStream; - -@Configuration -public class FirebaseConfig { - - @Value("${fcm.certification}") - private String credentialPath; - - @PostConstruct // 3. 의존성 주입이 완료된 후, 딱 한 번만 실행되도록 보장 - public void initialize() { - try { - // 4. 리소스 폴더의 인증 파일을 읽어옴 - ClassPathResource resource = new ClassPathResource(credentialPath); - InputStream inputStream = resource.getInputStream(); - - // 5. Firebase 옵션 설정 (인증 정보 세팅) - FirebaseOptions options = FirebaseOptions.builder() - .setCredentials(GoogleCredentials.fromStream(inputStream)) - .build(); - - // 6. FirebaseApp이 이미 초기화되어 있는지 확인 후 초기화 진행 - if (FirebaseApp.getApps().isEmpty()) { - FirebaseApp.initializeApp(options); - System.out.println("FCM 초기화 성공"); - } - } catch (IOException e) { - // 초기화 실패 시 서버 실행 시점에 에러를 파악할 수 있게 로깅함 - System.err.println("FCM 초기화 실패: " + e.getMessage()); - } - } -}