diff --git a/src/main/java/com/daramg/server/aicomment/application/AiCommentService.java b/src/main/java/com/daramg/server/aicomment/application/AiCommentService.java index 9033c94..7a5201c 100644 --- a/src/main/java/com/daramg/server/aicomment/application/AiCommentService.java +++ b/src/main/java/com/daramg/server/aicomment/application/AiCommentService.java @@ -145,7 +145,12 @@ public void scheduleForPost(Post post) { } @Transactional - public void scheduleReplyForAiComment(Comment aiComment, Post post) { + public void scheduleReplyForAiComment(Long aiCommentId, Long postId) { + Comment aiComment = commentRepository.findById(aiCommentId).orElse(null); + if (aiComment == null) { + return; + } + if (aiComment.getAiReplyCount() >= MAX_AI_REPLY_COUNT) { return; } @@ -160,6 +165,11 @@ public void scheduleReplyForAiComment(Comment aiComment, Post post) { return; } + Post post = postRepository.findById(postId).orElse(null); + if (post == null) { + return; + } + aiComment.incrementAiReplyCount(); Instant scheduledAt = Instant.now().plusSeconds(replyDelayMinutes * 60L); diff --git a/src/main/java/com/daramg/server/aicomment/event/AiReplyScheduleEvent.java b/src/main/java/com/daramg/server/aicomment/event/AiReplyScheduleEvent.java new file mode 100644 index 0000000..09cf084 --- /dev/null +++ b/src/main/java/com/daramg/server/aicomment/event/AiReplyScheduleEvent.java @@ -0,0 +1,7 @@ +package com.daramg.server.aicomment.event; + +public record AiReplyScheduleEvent( + Long aiCommentId, + Long postId +) { +} diff --git a/src/main/java/com/daramg/server/aicomment/event/AiReplyScheduleEventListener.java b/src/main/java/com/daramg/server/aicomment/event/AiReplyScheduleEventListener.java new file mode 100644 index 0000000..b523afb --- /dev/null +++ b/src/main/java/com/daramg/server/aicomment/event/AiReplyScheduleEventListener.java @@ -0,0 +1,26 @@ +package com.daramg.server.aicomment.event; + +import com.daramg.server.aicomment.application.AiCommentService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionalEventListener; + +import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AiReplyScheduleEventListener { + + private final AiCommentService aiCommentService; + + @TransactionalEventListener(phase = AFTER_COMMIT) + public void handleAiReplyScheduleEvent(AiReplyScheduleEvent event) { + try { + aiCommentService.scheduleReplyForAiComment(event.aiCommentId(), event.postId()); + } catch (Exception e) { + log.warn("AI 답글 잡 등록 실패 - aiCommentId={}, postId={}", event.aiCommentId(), event.postId(), e); + } + } +} diff --git a/src/main/java/com/daramg/server/comment/application/CommentService.java b/src/main/java/com/daramg/server/comment/application/CommentService.java index cbb57b8..a0f44c0 100644 --- a/src/main/java/com/daramg/server/comment/application/CommentService.java +++ b/src/main/java/com/daramg/server/comment/application/CommentService.java @@ -1,6 +1,6 @@ package com.daramg.server.comment.application; -import com.daramg.server.aicomment.application.AiCommentService; +import com.daramg.server.aicomment.event.AiReplyScheduleEvent; import com.daramg.server.comment.domain.Comment; import com.daramg.server.comment.domain.CommentLike; import com.daramg.server.comment.dto.CommentLikeResponseDto; @@ -30,7 +30,6 @@ public class CommentService { private final CommentRepository commentRepository; private final CommentLikeRepository commentLikeRepository; private final ApplicationEventPublisher eventPublisher; - private final AiCommentService aiCommentService; public void createComment(Long postId, CommentCreateDto request, User user){ Post post = entityUtils.getEntity(postId, Post.class); @@ -80,7 +79,7 @@ public void createReply(Long commentId, CommentReplyCreateDto request, User user } if (parentComment.isAi()) { - aiCommentService.scheduleReplyForAiComment(parentComment, post); + eventPublisher.publishEvent(new AiReplyScheduleEvent(parentComment.getId(), post.getId())); } } diff --git a/src/main/java/com/daramg/server/notification/application/NotificationService.java b/src/main/java/com/daramg/server/notification/application/NotificationService.java index 7860d9f..34367d4 100644 --- a/src/main/java/com/daramg/server/notification/application/NotificationService.java +++ b/src/main/java/com/daramg/server/notification/application/NotificationService.java @@ -2,22 +2,41 @@ import com.daramg.server.common.application.EntityUtils; import com.daramg.server.common.exception.BusinessException; -import com.daramg.server.notification.exception.NotificationErrorStatus; import com.daramg.server.notification.domain.Notification; +import com.daramg.server.notification.event.NotificationEvent; +import com.daramg.server.notification.exception.NotificationErrorStatus; import com.daramg.server.notification.repository.NotificationRepository; +import com.daramg.server.post.domain.Post; import com.daramg.server.user.domain.User; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service @Transactional @RequiredArgsConstructor public class NotificationService { + private static final int MAX_NOTIFICATIONS = 100; + private final NotificationRepository notificationRepository; private final EntityUtils entityUtils; + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void createFromEvent(NotificationEvent event) { + User receiver = entityUtils.getEntity(event.receiver().getId(), User.class); + User sender = entityUtils.getEntity(event.sender().getId(), User.class); + Post post = entityUtils.getEntity(event.post().getId(), Post.class); + + Notification notification = Notification.of(receiver, sender, post, event.type()); + notificationRepository.save(notification); + trimNotifications(receiver.getId()); + } + public void markAsRead(Long notificationId, User user) { Notification notification = entityUtils.getEntity(notificationId, Notification.class); validateReceiver(notification, user); @@ -34,6 +53,21 @@ public void delete(Long notificationId, User user) { notificationRepository.delete(notification); } + private void trimNotifications(Long receiverId) { + if (notificationRepository.countByReceiverId(receiverId) <= MAX_NOTIFICATIONS) { + return; + } + + List keepIds = notificationRepository.findRecentIdsByReceiverId( + receiverId, + PageRequest.of(0, MAX_NOTIFICATIONS) + ); + + if (!keepIds.isEmpty()) { + notificationRepository.deleteByReceiverIdAndIdNotIn(receiverId, keepIds); + } + } + private void validateReceiver(Notification notification, User user) { if (!notification.getReceiver().getId().equals(user.getId())) { throw new BusinessException(NotificationErrorStatus.NOT_OWN_NOTIFICATION); diff --git a/src/main/java/com/daramg/server/notification/event/NotificationEventListener.java b/src/main/java/com/daramg/server/notification/event/NotificationEventListener.java index d4ddc02..e40cc90 100644 --- a/src/main/java/com/daramg/server/notification/event/NotificationEventListener.java +++ b/src/main/java/com/daramg/server/notification/event/NotificationEventListener.java @@ -1,36 +1,37 @@ package com.daramg.server.notification.event; -import com.daramg.server.notification.domain.Notification; -import com.daramg.server.notification.repository.NotificationRepository; +import com.daramg.server.notification.application.NotificationService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionalEventListener; -import static org.springframework.transaction.event.TransactionPhase.BEFORE_COMMIT; +import static org.springframework.transaction.event.TransactionPhase.AFTER_COMMIT; +@Slf4j @Component @RequiredArgsConstructor public class NotificationEventListener { - private final NotificationRepository notificationRepository; + private final NotificationService notificationService; - @TransactionalEventListener(phase = BEFORE_COMMIT) + @TransactionalEventListener(phase = AFTER_COMMIT) public void handleNotificationEvent(NotificationEvent event) { if (event.receiver().getId().equals(event.sender().getId())) { return; } - Notification notification = Notification.of( - event.receiver(), - event.sender(), - event.post(), - event.type() - ); - - notificationRepository.save(notification); - - if (notificationRepository.countByReceiverId(event.receiver().getId()) > 100) { - notificationRepository.deleteOldestByReceiverIdExceedingLimit(event.receiver().getId()); + try { + notificationService.createFromEvent(event); + } catch (RuntimeException e) { + log.warn( + "Failed to process notification event. receiverId={}, senderId={}, postId={}, type={}", + event.receiver().getId(), + event.sender().getId(), + event.post().getId(), + event.type(), + e + ); } } } diff --git a/src/main/java/com/daramg/server/notification/repository/NotificationRepository.java b/src/main/java/com/daramg/server/notification/repository/NotificationRepository.java index 802a883..9f41e30 100644 --- a/src/main/java/com/daramg/server/notification/repository/NotificationRepository.java +++ b/src/main/java/com/daramg/server/notification/repository/NotificationRepository.java @@ -1,11 +1,14 @@ package com.daramg.server.notification.repository; import com.daramg.server.notification.domain.Notification; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import java.time.Instant; +import java.util.Collection; +import java.util.List; public interface NotificationRepository extends JpaRepository { @@ -18,8 +21,10 @@ public interface NotificationRepository extends JpaRepository findRecentIdsByReceiverId(Long receiverId, Pageable pageable); + @Modifying - @Query("DELETE FROM Notification n WHERE n.receiver.id = :receiverId AND n.id NOT IN " + - "(SELECT n2.id FROM Notification n2 WHERE n2.receiver.id = :receiverId ORDER BY n2.createdAt DESC LIMIT 100)") - void deleteOldestByReceiverIdExceedingLimit(Long receiverId); + @Query("DELETE FROM Notification n WHERE n.receiver.id = :receiverId AND n.id NOT IN :keepIds") + void deleteByReceiverIdAndIdNotIn(Long receiverId, Collection keepIds); } diff --git a/src/test/java/com/daramg/server/aicomment/application/AiCommentServiceTest.java b/src/test/java/com/daramg/server/aicomment/application/AiCommentServiceTest.java index 3ba1420..8075e4d 100644 --- a/src/test/java/com/daramg/server/aicomment/application/AiCommentServiceTest.java +++ b/src/test/java/com/daramg/server/aicomment/application/AiCommentServiceTest.java @@ -220,14 +220,14 @@ class ScheduleReplyTest { commentRepository.save(aiComment); // when - aiCommentService.scheduleReplyForAiComment(aiComment, post); + aiCommentService.scheduleReplyForAiComment(aiComment.getId(), post.getId()); // then List jobs = aiCommentJobRepository.findAll(); assertThat(jobs).hasSize(1); assertThat(jobs.get(0).getTriggerType()).isEqualTo(AiCommentJobTriggerType.USER_REPLY); assertThat(jobs.get(0).getParentComment().getId()).isEqualTo(aiComment.getId()); - assertThat(aiComment.getAiReplyCount()).isEqualTo((byte) 1); + assertThat(commentRepository.findById(aiComment.getId()).get().getAiReplyCount()).isEqualTo((byte) 1); } @Test @@ -240,9 +240,10 @@ class ScheduleReplyTest { Comment aiComment = Comment.ofAi(post, botUser, "AI 댓글", null, composer); commentRepository.save(aiComment); ReflectionTestUtils.setField(aiComment, "aiReplyCount", (byte) 2); + commentRepository.save(aiComment); // when - aiCommentService.scheduleReplyForAiComment(aiComment, post); + aiCommentService.scheduleReplyForAiComment(aiComment.getId(), post.getId()); // then assertThat(aiCommentJobRepository.findAll()).isEmpty(); diff --git a/src/test/java/com/daramg/server/notification/application/NotificationServiceIntegrationTest.java b/src/test/java/com/daramg/server/notification/application/NotificationServiceIntegrationTest.java index c75373a..fac3eec 100644 --- a/src/test/java/com/daramg/server/notification/application/NotificationServiceIntegrationTest.java +++ b/src/test/java/com/daramg/server/notification/application/NotificationServiceIntegrationTest.java @@ -8,11 +8,11 @@ import com.daramg.server.notification.domain.NotificationType; import com.daramg.server.notification.dto.NotificationResponseDto; import com.daramg.server.notification.repository.NotificationRepository; +import com.daramg.server.post.application.PostService; import com.daramg.server.post.domain.FreePost; import com.daramg.server.post.domain.Post; import com.daramg.server.post.domain.PostStatus; import com.daramg.server.post.domain.vo.PostCreateVo; -import com.daramg.server.post.application.PostService; import com.daramg.server.post.dto.CommentCreateDto; import com.daramg.server.post.dto.CommentReplyCreateDto; import com.daramg.server.post.repository.PostRepository; @@ -31,6 +31,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; public class NotificationServiceIntegrationTest extends ServiceTestSupport { @@ -61,6 +62,7 @@ public class NotificationServiceIntegrationTest extends ServiceTestSupport { private User postAuthor; private User actor; + private User seedSender; private Post post; @BeforeEach @@ -71,6 +73,9 @@ void setUp() { actor = new User("actor@test.com", "password", "행위자", LocalDate.now(), "profile", "행위자닉", "bio", null); userRepository.save(actor); + seedSender = new User("seed@test.com", "password", "시드", LocalDate.now(), "profile", "시드닉", "bio", null); + userRepository.save(seedSender); + post = FreePost.from(new PostCreateVo.Free( postAuthor, "제목", "내용", PostStatus.PUBLISHED, List.of(), null, List.of() )); @@ -242,6 +247,50 @@ class CommentLikeNotificationTest { } } + @Nested + @DisplayName("알림 개수 제한") + class NotificationLimitTest { + @Test + void 게시글_댓글_알림이_101개째_생겨도_댓글_생성은_성공하고_최신_100개만_유지한다() { + // given + createNotificationsUpToLimit(); + + // when + assertThatCode(() -> commentService.createComment(post.getId(), new CommentCreateDto("새 댓글"), actor)) + .doesNotThrowAnyException(); + + // then + assertThat(notificationRepository.countByReceiverId(postAuthor.getId())).isEqualTo(100); + assertThat(notificationRepository.findAll()) + .filteredOn(notification -> notification.getReceiver().getId().equals(postAuthor.getId())) + .anyMatch(notification -> + notification.getSender().getId().equals(actor.getId()) && + notification.getType() == NotificationType.COMMENT && + notification.getPost().getId().equals(post.getId()) + ); + } + + @Test + void 게시글_좋아요_알림이_101개째_생겨도_좋아요는_성공하고_최신_100개만_유지한다() { + // given + createNotificationsUpToLimit(); + + // when + assertThatCode(() -> postService.toggleLike(post.getId(), actor)) + .doesNotThrowAnyException(); + + // then + assertThat(notificationRepository.countByReceiverId(postAuthor.getId())).isEqualTo(100); + assertThat(notificationRepository.findAll()) + .filteredOn(notification -> notification.getReceiver().getId().equals(postAuthor.getId())) + .anyMatch(notification -> + notification.getSender().getId().equals(actor.getId()) && + notification.getType() == NotificationType.POST_LIKE && + notification.getPost().getId().equals(post.getId()) + ); + } + } + @Nested @DisplayName("알림 조회") class QueryNotificationTest { @@ -340,4 +389,15 @@ class DeleteIntegrationTest { .hasMessage("본인의 알림만 처리할 수 있습니다."); } } + + private void createNotificationsUpToLimit() { + for (int i = 0; i < 100; i++) { + notificationRepository.save(Notification.of( + postAuthor, + seedSender, + post, + NotificationType.COMMENT_LIKE + )); + } + } } diff --git a/src/test/java/com/daramg/server/notification/event/NotificationEventListenerTest.java b/src/test/java/com/daramg/server/notification/event/NotificationEventListenerTest.java new file mode 100644 index 0000000..0a67385 --- /dev/null +++ b/src/test/java/com/daramg/server/notification/event/NotificationEventListenerTest.java @@ -0,0 +1,66 @@ +package com.daramg.server.notification.event; + +import com.daramg.server.notification.application.NotificationService; +import com.daramg.server.notification.domain.NotificationType; +import com.daramg.server.post.domain.FreePost; +import com.daramg.server.post.domain.Post; +import com.daramg.server.post.domain.PostStatus; +import com.daramg.server.post.domain.vo.PostCreateVo; +import com.daramg.server.user.domain.User; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class NotificationEventListenerTest { + + @InjectMocks + private NotificationEventListener notificationEventListener; + + @Mock + private NotificationService notificationService; + + private NotificationEvent event; + + @BeforeEach + void setUp() { + User receiver = new User("receiver@test.com", "password", "receiver", LocalDate.now(), "profile", "받는사람", "bio", null); + ReflectionTestUtils.setField(receiver, "id", 1L); + + User sender = new User("sender@test.com", "password", "sender", LocalDate.now(), "profile", "보내는사람", "bio", null); + ReflectionTestUtils.setField(sender, "id", 2L); + + Post post = FreePost.from(new PostCreateVo.Free( + receiver, "제목", "내용", PostStatus.PUBLISHED, List.of(), null, List.of() + )); + ReflectionTestUtils.setField(post, "id", 1L); + + event = new NotificationEvent(receiver, sender, post, NotificationType.COMMENT); + } + + @Test + @DisplayName("알림 저장이 실패해도 예외를 다시 던지지 않는다") + void handleNotificationEventDoesNotRethrow() { + // given + doThrow(new RuntimeException("notification failed")) + .when(notificationService).createFromEvent(any(NotificationEvent.class)); + + // when & then + assertThatCode(() -> notificationEventListener.handleNotificationEvent(event)) + .doesNotThrowAnyException(); + verify(notificationService).createFromEvent(event); + } +}