Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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){
Expand Down Expand Up @@ -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 - 이 경우, 승인 대기중 상태도 같이 삭제 */
Expand All @@ -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());
}

// 팔로잉 사용자 목록 조회
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
public class DeviceTokenRequestDTO {

private String token;
private String platform; // 'ANDROID' or 'IOS' or 'WEB'
private String platform; // AND, IOS, WEB
}
Original file line number Diff line number Diff line change
@@ -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<String> to; // 수신자 토큰 리스트
private String title; // 알림 제목
private String body; // 알림 본문
private String sound; // "default"
private Map<String, Object> pushData; // 추가 데이터
}
Original file line number Diff line number Diff line change
@@ -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 // 어떤 질문인지
) {}
Original file line number Diff line number Diff line change
@@ -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 // 팔로우를 건 사람
) {}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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(),
Expand All @@ -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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -56,37 +73,52 @@ public void send(Member receiver, Member actor, NotificationType notificationTyp
.build();
notificationRepository.save(notifications);

List<DeviceToken> tokens = deviceTokenRepository.findAllByMemberId(receiver.getId());
// 2. 수신자의 모든 디바이스 토큰 조회
List<String> tokenStrings = deviceTokenRepository.findAllByMemberId(receiver.getId())
.stream()
.map(DeviceToken::getToken)
.collect(Collectors.toList());

// Expo 전송용 추가 데이터 구성
Map<String, Object> 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<String> targetTokens, String title, String body, Map<String, Object> 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);
});
}
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading