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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.daramg.server.aicomment.event;

public record AiReplyScheduleEvent(
Long aiCommentId,
Long postId
) {
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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()));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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<Long> 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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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
);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Notification, Long> {

Expand All @@ -18,8 +21,10 @@ public interface NotificationRepository extends JpaRepository<Notification, Long
@Query("UPDATE Notification n SET n.isRead = true WHERE n.receiver.id = :receiverId AND n.isRead = false")
void markAllAsReadByReceiverId(Long receiverId);

@Query("SELECT n.id FROM Notification n WHERE n.receiver.id = :receiverId ORDER BY n.createdAt DESC, n.id DESC")
List<Long> 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<Long> keepIds);
}
Original file line number Diff line number Diff line change
Expand Up @@ -220,14 +220,14 @@ class ScheduleReplyTest {
commentRepository.save(aiComment);

// when
aiCommentService.scheduleReplyForAiComment(aiComment, post);
aiCommentService.scheduleReplyForAiComment(aiComment.getId(), post.getId());

// then
List<AiCommentJob> 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
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -61,6 +62,7 @@ public class NotificationServiceIntegrationTest extends ServiceTestSupport {

private User postAuthor;
private User actor;
private User seedSender;
private Post post;

@BeforeEach
Expand All @@ -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()
));
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
));
}
}
}
Loading
Loading