diff --git a/clokey-api/src/main/java/org/clokey/domain/like/event/NewLikeEvent.java b/clokey-api/src/main/java/org/clokey/domain/like/event/NewLikeEvent.java new file mode 100644 index 00000000..ee2a68a2 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/like/event/NewLikeEvent.java @@ -0,0 +1,9 @@ +package org.clokey.domain.like.event; + +public record NewLikeEvent( + Long likeId, + Long historyId, + Long receiverId, + Long likerId, + String likerNickname, + String likerProfileImageUrl) {} diff --git a/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java index e27c1c7e..dcf7d047 100644 --- a/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java @@ -12,6 +12,7 @@ import org.clokey.domain.history.repository.HistoryRepository; import org.clokey.domain.like.dto.response.LikedHistoriesResponse; import org.clokey.domain.like.dto.response.LikedMembersResponse; +import org.clokey.domain.like.event.NewLikeEvent; import org.clokey.domain.like.repository.MemberLikeRepository; import org.clokey.domain.like.repository.MemberLikeRepositoryCustom; import org.clokey.domain.member.repository.BlockRepository; @@ -111,6 +112,14 @@ public void toggleLike(Long historyId) { } else { MemberLike newLike = MemberLike.createMemberLike(currentMember, history); memberLikeRepository.save(newLike); + eventPublisher.publishEvent( + new NewLikeEvent( + newLike.getId(), + history.getId(), + historyOwner.getId(), + currentMember.getId(), + currentMember.getNickname(), + currentMember.getProfileImageUrl())); } eventPublisher.publishEvent( diff --git a/clokey-api/src/main/java/org/clokey/domain/notification/event/NotificationEventListener.java b/clokey-api/src/main/java/org/clokey/domain/notification/event/NotificationEventListener.java index a4c554ea..91684b9b 100644 --- a/clokey-api/src/main/java/org/clokey/domain/notification/event/NotificationEventListener.java +++ b/clokey-api/src/main/java/org/clokey/domain/notification/event/NotificationEventListener.java @@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j; import org.clokey.domain.comment.event.NewCommentEvent; import org.clokey.domain.comment.event.NewReplyEvent; +import org.clokey.domain.like.event.NewLikeEvent; import org.clokey.domain.member.event.NewFollowerEvent; import org.clokey.domain.member.event.NewPendingFollowerEvent; import org.clokey.domain.notification.service.CodiveNotificationService; @@ -84,4 +85,18 @@ public void handleNewReply(NewReplyEvent event) { e); } } + + @Async + @TransactionalEventListener(classes = NewLikeEvent.class, phase = TransactionPhase.AFTER_COMMIT) + public void handleNewLike(NewLikeEvent event) { + try { + codiveNotificationService.sendNewLikeNotification(event); + } catch (Exception e) { + log.error( + "새 좋아요 알림 전송 실패 - historyId: {}, likeId: {}", + event.historyId(), + event.likeId(), + e); + } + } } diff --git a/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationService.java b/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationService.java index a9bbb400..bb85c15e 100644 --- a/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationService.java +++ b/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationService.java @@ -2,6 +2,7 @@ import org.clokey.domain.comment.event.NewCommentEvent; import org.clokey.domain.comment.event.NewReplyEvent; +import org.clokey.domain.like.event.NewLikeEvent; import org.clokey.domain.notification.dto.request.TemperatureNotificationRequest; import org.clokey.domain.notification.dto.response.NotificationListResponse; import org.clokey.domain.notification.dto.response.UnreadNotificationResponse; @@ -17,6 +18,8 @@ public interface CodiveNotificationService { void sendNewReplyNotification(NewReplyEvent event); + void sendNewLikeNotification(NewLikeEvent event); + void sendNewTemperatureNotification(TemperatureNotificationRequest request); SliceResponse getNotificationList( diff --git a/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationServiceImpl.java index ab6df2ca..c303e6e0 100644 --- a/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationServiceImpl.java @@ -5,6 +5,7 @@ import com.google.firebase.messaging.Message; import com.google.firebase.messaging.Notification; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.clokey.comment.entitiy.Comment; import org.clokey.domain.comment.event.NewCommentEvent; import org.clokey.domain.comment.event.NewReplyEvent; @@ -12,6 +13,7 @@ import org.clokey.domain.comment.repository.CommentRepository; import org.clokey.domain.history.exception.HistoryErrorCode; import org.clokey.domain.history.repository.HistoryRepository; +import org.clokey.domain.like.event.NewLikeEvent; import org.clokey.domain.member.exception.MemberErrorCode; import org.clokey.domain.member.repository.MemberRepository; import org.clokey.domain.notification.dto.request.TemperatureNotificationRequest; @@ -32,9 +34,15 @@ import org.clokey.notification.enums.ReadStatus; import org.clokey.notification.enums.RedirectType; import org.clokey.response.SliceResponse; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -46,6 +54,7 @@ public class CodiveNotificationServiceImpl implements CodiveNotificationService private final HistoryRepository historyRepository; private final CommentRepository commentRepository; private final FirebaseMessaging firebaseMessaging; + private final ApplicationEventPublisher eventPublisher; private final MemberUtil memberUtil; @@ -53,12 +62,14 @@ public class CodiveNotificationServiceImpl implements CodiveNotificationService private static final String NEW_PENDING_FOLLOWER_NOTIFICATION = "%s님이 회원님의 옷장에 팔로우를 요청했습니다."; private static final String NEW_COMMENT_NOTIFICATION = "%s님이 회원님의 기록에 댓글을 남겼습니다. : %s"; private static final String NEW_REPLY_NOTIFICATION = "%s님이 회원님의 댓글에 답장을 남겼습니다. : %s"; + private static final String NEW_LIKE_NOTIFICATION = "%s님이 회원님의 기록을 좋아합니다."; private static final String TODAY_TEMPERATURE_NOTIFICATION = "오늘의 기온은 %d도 입니다!\n날씨에 맞는 오늘의 옷차림이 기다리고 있어요👀"; private static final String TODAY_TEMPERATURE_IMAGE_URL = "https://example.com/temperature.png"; @Override + @Transactional public void sendNewFollowerNotification(Long followFromId, Long followToId) { Member followFromMember = getMemberById(followFromId); Member followToMember = getMemberById(followToId); @@ -80,12 +91,6 @@ public void sendNewFollowerNotification(Long followFromId, Long followToId) { .putData("redirectType", "MEMBER_PROFILE") .build(); - try { - firebaseMessaging.send(message); - } catch (FirebaseMessagingException e) { - throw new BaseCustomException(NotificationErrorCode.NOTIFICATION_FIREBASE_ERROR); - } - CodiveNotification codiveNotification = CodiveNotification.createCodiveNotification( followToMember, @@ -96,10 +101,12 @@ public void sendNewFollowerNotification(Long followFromId, Long followToId) { NotificationType.FOLLOW); codiveNotificationRepository.save(codiveNotification); + eventPublisher.publishEvent(PushSendEvent.from(message)); } } @Override + @Transactional public void sendNewPendingFollowerNotification(Long followFromId, Long followToId) { Member followFromMember = getMemberById(followFromId); Member followToMember = getMemberById(followToId); @@ -122,12 +129,6 @@ public void sendNewPendingFollowerNotification(Long followFromId, Long followToI .putData("redirectType", "MEMBER_PROFILE") .build(); - try { - firebaseMessaging.send(message); - } catch (FirebaseMessagingException e) { - throw new BaseCustomException(NotificationErrorCode.NOTIFICATION_FIREBASE_ERROR); - } - CodiveNotification codiveNotification = CodiveNotification.createCodiveNotification( followToMember, @@ -138,10 +139,12 @@ public void sendNewPendingFollowerNotification(Long followFromId, Long followToI NotificationType.FOLLOW_REQUEST); codiveNotificationRepository.save(codiveNotification); + eventPublisher.publishEvent(PushSendEvent.from(message)); } } @Override + @Transactional public void sendNewCommentNotification(NewCommentEvent event) { Member receiver = getMemberById(event.receiverId()); @@ -165,12 +168,6 @@ public void sendNewCommentNotification(NewCommentEvent event) { .putData("commentId", String.valueOf(event.commentId())) .putData("commenterId", String.valueOf(event.commenterId())) .build(); - - try { - firebaseMessaging.send(message); - } catch (FirebaseMessagingException e) { - throw new BaseCustomException(NotificationErrorCode.NOTIFICATION_FIREBASE_ERROR); - } CodiveNotification codiveNotification = CodiveNotification.createCodiveNotification( receiver, @@ -181,10 +178,12 @@ public void sendNewCommentNotification(NewCommentEvent event) { NotificationType.COMMENT); codiveNotificationRepository.save(codiveNotification); + eventPublisher.publishEvent(PushSendEvent.from(message)); } } @Override + @Transactional public void sendNewReplyNotification(NewReplyEvent event) { Member receiver = getMemberById(event.receiverId()); @@ -205,12 +204,6 @@ public void sendNewReplyNotification(NewReplyEvent event) { .putData("replierId", String.valueOf(event.replierId())) .build(); - try { - firebaseMessaging.send(message); - } catch (FirebaseMessagingException e) { - throw new BaseCustomException(NotificationErrorCode.NOTIFICATION_FIREBASE_ERROR); - } - CodiveNotification codiveNotification = CodiveNotification.createCodiveNotification( receiver, @@ -221,10 +214,45 @@ public void sendNewReplyNotification(NewReplyEvent event) { NotificationType.REPLY); codiveNotificationRepository.save(codiveNotification); + eventPublisher.publishEvent(PushSendEvent.from(message)); } } @Override + @Transactional + public void sendNewLikeNotification(NewLikeEvent event) { + Member receiver = getMemberById(event.receiverId()); + + if (isAbleToSendNotification(receiver)) { + String content = String.format(NEW_LIKE_NOTIFICATION, event.likerNickname()); + String profileImageUrl = event.likerProfileImageUrl(); + + Notification notification = + Notification.builder().setBody(content).setImage(profileImageUrl).build(); + Message message = + Message.builder() + .setToken(receiver.getDeviceToken()) + .setNotification(notification) + .putData("historyId", String.valueOf(event.historyId())) + .putData("likerId", String.valueOf(event.likerId())) + .putData("likerNickName", String.valueOf(event.likerNickname())) + .build(); + CodiveNotification codiveNotification = + CodiveNotification.createCodiveNotification( + receiver, + content, + profileImageUrl, + event.likerNickname(), + RedirectType.MEMBER_REDIRECT, + NotificationType.LIKE); + + codiveNotificationRepository.save(codiveNotification); + eventPublisher.publishEvent(PushSendEvent.from(message)); + } + } + + @Override + @Transactional public void sendNewTemperatureNotification(TemperatureNotificationRequest request) { Member receiver = memberUtil.getCurrentMember(); String content = @@ -242,12 +270,6 @@ public void sendNewTemperatureNotification(TemperatureNotificationRequest reques .setNotification(notification) .build(); - try { - firebaseMessaging.send(message); - } catch (FirebaseMessagingException e) { - throw new BaseCustomException(NotificationErrorCode.NOTIFICATION_FIREBASE_ERROR); - } - CodiveNotification codiveNotification = CodiveNotification.createCodiveNotification( receiver, @@ -258,6 +280,7 @@ public void sendNewTemperatureNotification(TemperatureNotificationRequest reques NotificationType.TEMPERATURE_DAILY); codiveNotificationRepository.save(codiveNotification); + eventPublisher.publishEvent(PushSendEvent.from(message)); } } @@ -342,4 +365,22 @@ private boolean isAbleToSendNotification(Member followToMember) { return isActive && hasDeviceToken && hasAgreed; } + + @Async + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendPushAfterCommit(PushSendEvent event) { + try { + firebaseMessaging.send(event.message()); + } catch (FirebaseMessagingException e) { + log.warn("[Notification] Firebase 전송 실패", e); + throw new BaseCustomException(NotificationErrorCode.NOTIFICATION_FIREBASE_ERROR); + } + } + + private record PushSendEvent(Message message) { + private static PushSendEvent from(Message message) { + return new PushSendEvent(message); + } + } } diff --git a/clokey-api/src/test/java/org/clokey/domain/notification/service/NotificationServiceTest.java b/clokey-api/src/test/java/org/clokey/domain/notification/service/NotificationServiceTest.java index 7fbde904..045cfcef 100644 --- a/clokey-api/src/test/java/org/clokey/domain/notification/service/NotificationServiceTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/notification/service/NotificationServiceTest.java @@ -20,6 +20,7 @@ import org.clokey.domain.comment.service.CommentService; import org.clokey.domain.history.repository.HistoryRepository; import org.clokey.domain.history.repository.SituationRepository; +import org.clokey.domain.like.event.NewLikeEvent; import org.clokey.domain.member.event.NewFollowerEvent; import org.clokey.domain.member.event.NewPendingFollowerEvent; import org.clokey.domain.member.repository.MemberRepository; @@ -330,6 +331,51 @@ void setUp() { } } + @Nested + class 기록에_좋아요가_달렸을_때 { + + @BeforeEach + void setUp() { + Member liker = + Member.createMember( + "testEmail1", + "liker", + OauthInfo.createOauthInfo("testOauthId1", OauthProvider.KAKAO)); + liker.updateProfile("liker", "example.com", "한줄소개~", Visibility.PUBLIC); + Member receiver = + Member.createMember( + "testEmail2", + "receiver", + OauthInfo.createOauthInfo("testOauthId2", OauthProvider.KAKAO)); + receiver.updateDeviceToken("test-device-token-for-member2"); + memberRepository.saveAll(List.of(liker, receiver)); + + MemberTerm mockAgreement = Mockito.mock(MemberTerm.class); + given(mockAgreement.isAgreed()).willReturn(true); + + given( + memberTermRepository.findByMemberIdAndTermId( + eq(receiver.getId()), + eq(TermInfo.PUSH_NOTIFICATION_RECEIVE.getId()))) + .willReturn(Optional.of(mockAgreement)); + } + + @Test + void 유효한_요청이면_새_좋아요_알림을_저장한다() { + // given + NewLikeEvent event = new NewLikeEvent(1L, 1L, 2L, 1L, "liker", "example.com"); + + // when + notificationService.sendNewLikeNotification(event); + + // then + Optional notification = notificationRepository.findById(1L); + + assertThat(notification.get().getMember().getId()).isEqualTo(2L); + assertThat(notification.get().getContent()).isEqualTo("liker님이 회원님의 기록을 좋아합니다."); + } + } + @Nested class 안읽은_알림_여부를_확인할_때 { @@ -501,6 +547,7 @@ class 알림_상태를_읽음으로_변경_요청했을_때 { @BeforeEach void setUp() { + Mockito.reset(firebaseMessaging); Member member1 = Member.createMember( "testEmail1", @@ -633,7 +680,8 @@ void setUp() { // then // firebaseMessaging.send() 메서드가 1번 호출되었는지 검증 - Mockito.verify(firebaseMessaging, Mockito.times(1)).send(Mockito.any(Message.class)); + Mockito.verify(firebaseMessaging, Mockito.timeout(1000).times(1)) + .send(Mockito.any(Message.class)); } @Test @@ -655,7 +703,8 @@ void setUp() { notificationService.sendNewTemperatureNotification(request); // then - Mockito.verify(firebaseMessaging, Mockito.times(0)).send(Mockito.any(Message.class)); + Mockito.verify(firebaseMessaging, Mockito.after(200).never()) + .send(Mockito.any(Message.class)); } } } diff --git a/clokey-domain/src/main/java/org/clokey/notification/enums/NotificationType.java b/clokey-domain/src/main/java/org/clokey/notification/enums/NotificationType.java index 11d6fd0e..d4aa5937 100644 --- a/clokey-domain/src/main/java/org/clokey/notification/enums/NotificationType.java +++ b/clokey-domain/src/main/java/org/clokey/notification/enums/NotificationType.java @@ -5,5 +5,6 @@ public enum NotificationType { FOLLOW_REQUEST, COMMENT, REPLY, + LIKE, TEMPERATURE_DAILY }