diff --git a/src/main/java/com/back/domain/board/comment/service/CommentService.java b/src/main/java/com/back/domain/board/comment/service/CommentService.java index 5bf185f2..64b7ab46 100644 --- a/src/main/java/com/back/domain/board/comment/service/CommentService.java +++ b/src/main/java/com/back/domain/board/comment/service/CommentService.java @@ -9,11 +9,14 @@ import com.back.domain.board.post.entity.Post; import com.back.domain.board.comment.repository.CommentRepository; import com.back.domain.board.post.repository.PostRepository; +import com.back.domain.notification.event.community.CommentCreatedEvent; +import com.back.domain.notification.event.community.ReplyCreatedEvent; import com.back.domain.user.entity.User; import com.back.domain.user.repository.UserRepository; import com.back.global.exception.CustomException; import com.back.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -26,6 +29,7 @@ public class CommentService { private final CommentRepository commentRepository; private final UserRepository userRepository; private final PostRepository postRepository; + private final ApplicationEventPublisher eventPublisher; /** * 댓글 생성 서비스 @@ -48,6 +52,18 @@ public CommentResponse createComment(Long postId, CommentRequest request, Long u // Comment 저장 및 응답 반환 commentRepository.save(comment); + + // 댓글 작성 이벤트 발행 + eventPublisher.publishEvent( + new CommentCreatedEvent( + userId, // 댓글 작성자 + post.getUser().getId(), // 게시글 작성자 + postId, + comment.getId(), + request.content() + ) + ); + return CommentResponse.from(comment); } @@ -159,6 +175,19 @@ public ReplyResponse createReply(Long postId, Long parentCommentId, CommentReque // 저장 및 응답 반환 commentRepository.save(reply); + + // 대댓글 작성 이벤트 발행 + eventPublisher.publishEvent( + new ReplyCreatedEvent( + userId, // 대댓글 작성자 + parent.getUser().getId(), // 댓글 작성자 + postId, + parentCommentId, + reply.getId(), + request.content() + ) + ); + return ReplyResponse.from(reply); } } diff --git a/src/main/java/com/back/domain/notification/controller/NotificationController.java b/src/main/java/com/back/domain/notification/controller/NotificationController.java index 446c4bcd..d5f5b74b 100644 --- a/src/main/java/com/back/domain/notification/controller/NotificationController.java +++ b/src/main/java/com/back/domain/notification/controller/NotificationController.java @@ -29,7 +29,7 @@ @RestController @RequiredArgsConstructor @RequestMapping("/api/notifications") -@Tag(name = "알림", description = "알림 관련 API") +@Tag(name = "Notification API", description = "알림 관련 API") public class NotificationController { private final NotificationService notificationService; diff --git a/src/main/java/com/back/domain/notification/event/community/CommentCreatedEvent.java b/src/main/java/com/back/domain/notification/event/community/CommentCreatedEvent.java new file mode 100644 index 00000000..926b1c39 --- /dev/null +++ b/src/main/java/com/back/domain/notification/event/community/CommentCreatedEvent.java @@ -0,0 +1,33 @@ +package com.back.domain.notification.event.community; + +import lombok.Getter; + +@Getter +public class CommentCreatedEvent extends CommunityNotificationEvent { + private final Long postId; + private final Long commentId; + private final String commentContent; + + public CommentCreatedEvent(Long actorId, Long postAuthorId, Long postId, + Long commentId, String commentContent) { + super( + actorId, + postAuthorId, + postId, + "새 댓글", + "회원님의 게시글에 댓글이 달렸습니다" + ); + this.postId = postId; + this.commentId = commentId; + this.commentContent = commentContent; + } + + public String getContentPreview() { + if (commentContent == null || commentContent.isEmpty()) { + return ""; + } + return commentContent.length() > 50 + ? commentContent.substring(0, 50) + "..." + : commentContent; + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/event/community/CommentLikedEvent.java b/src/main/java/com/back/domain/notification/event/community/CommentLikedEvent.java new file mode 100644 index 00000000..e86c94d8 --- /dev/null +++ b/src/main/java/com/back/domain/notification/event/community/CommentLikedEvent.java @@ -0,0 +1,24 @@ +package com.back.domain.notification.event.community; + +import lombok.Getter; + +@Getter +public class CommentLikedEvent extends CommunityNotificationEvent { + private final Long postId; + private final Long commentId; + private final String commentContent; + + public CommentLikedEvent(Long actorId, Long commentAuthorId, Long postId, + Long commentId, String commentContent) { + super( + actorId, + commentAuthorId, + commentId, + "좋아요", + "회원님의 댓글을 좋아합니다" + ); + this.postId = postId; + this.commentId = commentId; + this.commentContent = commentContent; + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/event/community/CommunityNotificationEvent.java b/src/main/java/com/back/domain/notification/event/community/CommunityNotificationEvent.java new file mode 100644 index 00000000..9b8796d3 --- /dev/null +++ b/src/main/java/com/back/domain/notification/event/community/CommunityNotificationEvent.java @@ -0,0 +1,23 @@ +package com.back.domain.notification.event.community; + +import com.back.domain.notification.entity.NotificationType; +import lombok.Getter; + +@Getter +public abstract class CommunityNotificationEvent { + private final Long actorId; // 행동한 사람 (댓글 작성자, 좋아요 누른 사람) + private final Long receiverId; // 알림 받을 사람 (게시글/댓글 작성자) + private final NotificationType targetType = NotificationType.COMMUNITY; + private final Long targetId; // 게시글 ID 또는 댓글 ID + private final String title; + private final String content; + + protected CommunityNotificationEvent(Long actorId, Long receiverId, Long targetId, + String title, String content) { + this.actorId = actorId; + this.receiverId = receiverId; + this.targetId = targetId; + this.title = title; + this.content = content; + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/event/community/CommunityNotificationEventListener.java b/src/main/java/com/back/domain/notification/event/community/CommunityNotificationEventListener.java new file mode 100644 index 00000000..0ba86bbf --- /dev/null +++ b/src/main/java/com/back/domain/notification/event/community/CommunityNotificationEventListener.java @@ -0,0 +1,141 @@ +package com.back.domain.notification.event.community; + +import com.back.domain.notification.service.NotificationService; +import com.back.domain.user.entity.User; +import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CommunityNotificationEventListener { + + private final NotificationService notificationService; + private final UserRepository userRepository; + + // 댓글 작성 시 - 게시글 작성자에게 알림 + @EventListener + @Async("notificationExecutor") + public void handleCommentCreated(CommentCreatedEvent event) { + log.info("[알림] 댓글 작성: postId={}, commentId={}, actorId={}", + event.getPostId(), event.getCommentId(), event.getActorId()); + + try { + + User actor = userRepository.findById(event.getActorId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + User receiver = userRepository.findById(event.getReceiverId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + notificationService.createCommunityNotification( + receiver, + actor, + event.getTitle(), + event.getContent(), + "/posts/" + event.getPostId() + ); + + log.info("[알림] 댓글 작성 알림 전송 완료"); + + } catch (Exception e) { + log.error("[알림] 댓글 작성 알림 전송 실패: error={}", e.getMessage(), e); + } + } + + // 대댓글 작성 시 - 댓글 작성자에게 알림 + @EventListener + @Async("notificationExecutor") + public void handleReplyCreated(ReplyCreatedEvent event) { + log.info("[알림] 대댓글 작성: parentCommentId={}, replyId={}, actorId={}", + event.getParentCommentId(), event.getReplyId(), event.getActorId()); + + try { + + User actor = userRepository.findById(event.getActorId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + User receiver = userRepository.findById(event.getReceiverId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + notificationService.createCommunityNotification( + receiver, + actor, + event.getTitle(), + event.getContent(), + "/posts/" + event.getPostId() + "#comment-" + event.getParentCommentId() + ); + + log.info("[알림] 대댓글 작성 알림 전송 완료"); + + } catch (Exception e) { + log.error("[알림] 대댓글 작성 알림 전송 실패: error={}", e.getMessage(), e); + } + } + + // 게시글 좋아요 시 - 게시글 작성자에게 알림 + @EventListener + @Async("notificationExecutor") + public void handlePostLiked(PostLikedEvent event) { + log.info("[알림] 게시글 좋아요: postId={}, actorId={}", + event.getPostId(), event.getActorId()); + + try { + + User actor = userRepository.findById(event.getActorId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + User receiver = userRepository.findById(event.getReceiverId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + notificationService.createCommunityNotification( + receiver, + actor, + event.getTitle(), + event.getContent(), + "/posts/" + event.getPostId() + ); + + log.info("[알림] 게시글 좋아요 알림 전송 완료"); + + } catch (Exception e) { + log.error("[알림] 게시글 좋아요 알림 전송 실패: error={}", e.getMessage(), e); + } + } + + // 댓글 좋아요 시 - 댓글 작성자에게 알림 + @EventListener + @Async("notificationExecutor") + public void handleCommentLiked(CommentLikedEvent event) { + log.info("[알림] 댓글 좋아요: commentId={}, actorId={}", + event.getCommentId(), event.getActorId()); + + try { + + User actor = userRepository.findById(event.getActorId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + User receiver = userRepository.findById(event.getReceiverId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + notificationService.createCommunityNotification( + receiver, + actor, + event.getTitle(), + event.getContent(), + "/posts/" + event.getPostId() + "#comment-" + event.getCommentId() + ); + + log.info("[알림] 댓글 좋아요 알림 전송 완료"); + + } catch (Exception e) { + log.error("[알림] 댓글 좋아요 알림 전송 실패: error={}", e.getMessage(), e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/event/community/PostLikedEvent.java b/src/main/java/com/back/domain/notification/event/community/PostLikedEvent.java new file mode 100644 index 00000000..9d17df51 --- /dev/null +++ b/src/main/java/com/back/domain/notification/event/community/PostLikedEvent.java @@ -0,0 +1,21 @@ +package com.back.domain.notification.event.community; + +import lombok.Getter; + +@Getter +public class PostLikedEvent extends CommunityNotificationEvent { + private final Long postId; + private final String postTitle; + + public PostLikedEvent(Long actorId, Long postAuthorId, Long postId, String postTitle) { + super( + actorId, + postAuthorId, + postId, + "좋아요", + "회원님의 게시글을 좋아합니다" + ); + this.postId = postId; + this.postTitle = postTitle; + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/event/community/ReplyCreatedEvent.java b/src/main/java/com/back/domain/notification/event/community/ReplyCreatedEvent.java new file mode 100644 index 00000000..3a0c3093 --- /dev/null +++ b/src/main/java/com/back/domain/notification/event/community/ReplyCreatedEvent.java @@ -0,0 +1,35 @@ +package com.back.domain.notification.event.community; + +import lombok.Getter; + +@Getter +public class ReplyCreatedEvent extends CommunityNotificationEvent { + private final Long postId; + private final Long parentCommentId; + private final Long replyId; + private final String replyContent; + + public ReplyCreatedEvent(Long actorId, Long commentAuthorId, Long postId, + Long parentCommentId, Long replyId, String replyContent) { + super( + actorId, + commentAuthorId, + parentCommentId, + "새 대댓글", + "회원님의 댓글에 답글이 달렸습니다" + ); + this.postId = postId; + this.parentCommentId = parentCommentId; + this.replyId = replyId; + this.replyContent = replyContent; + } + + public String getContentPreview() { + if (replyContent == null || replyContent.isEmpty()) { + return ""; + } + return replyContent.length() > 50 + ? replyContent.substring(0, 50) + "..." + : replyContent; + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/event/study/DailyGoalAchievedEvent.java b/src/main/java/com/back/domain/notification/event/study/DailyGoalAchievedEvent.java new file mode 100644 index 00000000..9b6aac59 --- /dev/null +++ b/src/main/java/com/back/domain/notification/event/study/DailyGoalAchievedEvent.java @@ -0,0 +1,25 @@ +package com.back.domain.notification.event.study; + +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +public class DailyGoalAchievedEvent extends StudyNotificationEvent { + private final LocalDate achievedDate; + private final int completedPlans; + private final int totalPlans; + + public DailyGoalAchievedEvent(Long userId, LocalDate achievedDate, + int completedPlans, int totalPlans) { + super( + userId, + "일일 목표 달성 🎉", + String.format("오늘의 학습 계획을 모두 완료했습니다! (%d/%d)", + completedPlans, totalPlans) + ); + this.achievedDate = achievedDate; + this.completedPlans = completedPlans; + this.totalPlans = totalPlans; + } +} diff --git a/src/main/java/com/back/domain/notification/event/study/StudyNotificationEvent.java b/src/main/java/com/back/domain/notification/event/study/StudyNotificationEvent.java new file mode 100644 index 00000000..31add8a9 --- /dev/null +++ b/src/main/java/com/back/domain/notification/event/study/StudyNotificationEvent.java @@ -0,0 +1,18 @@ +package com.back.domain.notification.event.study; + +import com.back.domain.notification.entity.NotificationType; +import lombok.Getter; + +@Getter +public abstract class StudyNotificationEvent { + private final Long userId; // 알림 받을 사용자 (본인) + private final NotificationType targetType = NotificationType.PERSONAL; + private final String title; + private final String content; + + protected StudyNotificationEvent(Long userId, String title, String content) { + this.userId = userId; + this.title = title; + this.content = content; + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/event/study/StudyNotificationEventListener.java b/src/main/java/com/back/domain/notification/event/study/StudyNotificationEventListener.java new file mode 100644 index 00000000..ad838316 --- /dev/null +++ b/src/main/java/com/back/domain/notification/event/study/StudyNotificationEventListener.java @@ -0,0 +1,75 @@ +package com.back.domain.notification.event.study; + +import com.back.domain.notification.service.NotificationService; +import com.back.domain.user.entity.User; +import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class StudyNotificationEventListener { + + private final NotificationService notificationService; + private final UserRepository userRepository; + + // 학습 기록 등록 시 - 본인에게 알림 + @EventListener + @Async("notificationExecutor") + public void handleStudyRecordCreated(StudyRecordCreatedEvent event) { + log.info("[알림] 학습 기록 등록: userId={}, duration={}초", + event.getUserId(), event.getDuration()); + + try { + User user = userRepository.findById(event.getUserId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // 본인에게 개인 알림 + notificationService.createPersonalNotification( + user, // receiver (본인) + user, // actor (본인) + event.getTitle(), + event.getContent(), + "/study/records/" + event.getStudyRecordId() + ); + + log.info("[알림] 학습 기록 알림 전송 완료"); + + } catch (Exception e) { + log.error("[알림] 학습 기록 알림 전송 실패: error={}", e.getMessage(), e); + } + } + + // 일일 목표 달성 시 - 본인에게 알림 + @EventListener + @Async("notificationExecutor") + public void handleDailyGoalAchieved(DailyGoalAchievedEvent event) { + log.info("[알림] 일일 목표 달성: userId={}, date={}, 완료={}/{}", + event.getUserId(), event.getAchievedDate(), + event.getCompletedPlans(), event.getTotalPlans()); + + try { + User user = userRepository.findById(event.getUserId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + notificationService.createPersonalNotification( + user, + user, + event.getTitle(), + event.getContent(), + "/study/plans?date=" + event.getAchievedDate() + ); + + log.info("[알림] 일일 목표 달성 알림 전송 완료"); + + } catch (Exception e) { + log.error("[알림] 일일 목표 달성 알림 전송 실패: error={}", e.getMessage(), e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/event/study/StudyRecordCreatedEvent.java b/src/main/java/com/back/domain/notification/event/study/StudyRecordCreatedEvent.java new file mode 100644 index 00000000..dd44b17e --- /dev/null +++ b/src/main/java/com/back/domain/notification/event/study/StudyRecordCreatedEvent.java @@ -0,0 +1,31 @@ +package com.back.domain.notification.event.study; + +import lombok.Getter; + +@Getter +public class StudyRecordCreatedEvent extends StudyNotificationEvent { + private final Long studyRecordId; + private final Long studyPlanId; + private final Long duration; // 초 단위 + + public StudyRecordCreatedEvent(Long userId, Long studyRecordId, Long studyPlanId, Long duration) { + super( + userId, + "학습 기록 등록", + formatDuration(duration) + " 공부하셨습니다! 수고하셨어요 🎉" + ); + this.studyRecordId = studyRecordId; + this.studyPlanId = studyPlanId; + this.duration = duration; + } + + private static String formatDuration(Long seconds) { + long hours = seconds / 3600; + long minutes = (seconds % 3600) / 60; + + if (hours > 0) { + return hours + "시간 " + minutes + "분"; + } + return minutes + "분"; + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/event/studyroom/MemberKickedEvent.java b/src/main/java/com/back/domain/notification/event/studyroom/MemberKickedEvent.java new file mode 100644 index 00000000..0df7d316 --- /dev/null +++ b/src/main/java/com/back/domain/notification/event/studyroom/MemberKickedEvent.java @@ -0,0 +1,20 @@ +package com.back.domain.notification.event.studyroom; + +import lombok.Getter; + +@Getter +public class MemberKickedEvent extends StudyRoomNotificationEvent { + private final Long targetUserId; + private final String roomName; + + public MemberKickedEvent(Long actorId, Long studyRoomId, Long targetUserId, String roomName) { + super( + actorId, + studyRoomId, + "스터디룸 퇴출", + String.format("%s 스터디룸에서 퇴출되었습니다", roomName) + ); + this.targetUserId = targetUserId; + this.roomName = roomName; + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/event/studyroom/MemberRoleChangedEvent.java b/src/main/java/com/back/domain/notification/event/studyroom/MemberRoleChangedEvent.java new file mode 100644 index 00000000..c622a701 --- /dev/null +++ b/src/main/java/com/back/domain/notification/event/studyroom/MemberRoleChangedEvent.java @@ -0,0 +1,20 @@ +package com.back.domain.notification.event.studyroom; + +import lombok.Getter; + +@Getter +public class MemberRoleChangedEvent extends StudyRoomNotificationEvent { + private final Long targetUserId; + private final String newRole; + + public MemberRoleChangedEvent(Long actorId, Long studyRoomId, Long targetUserId, String newRole) { + super( + actorId, + studyRoomId, + "권한 변경", + String.format("회원님의 권한이 %s(으)로 변경되었습니다", newRole) + ); + this.targetUserId = targetUserId; + this.newRole = newRole; + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/event/studyroom/OwnerTransferredEvent.java b/src/main/java/com/back/domain/notification/event/studyroom/OwnerTransferredEvent.java new file mode 100644 index 00000000..d824ad1b --- /dev/null +++ b/src/main/java/com/back/domain/notification/event/studyroom/OwnerTransferredEvent.java @@ -0,0 +1,20 @@ +package com.back.domain.notification.event.studyroom; + +import lombok.Getter; + +@Getter +public class OwnerTransferredEvent extends StudyRoomNotificationEvent { + private final Long newOwnerId; + private final String roomName; + + public OwnerTransferredEvent(Long actorId, Long studyRoomId, Long newOwnerId, String roomName) { + super( + actorId, + studyRoomId, + "방장 위임", + String.format("%s 스터디룸의 새로운 방장이 되었습니다", roomName) + ); + this.newOwnerId = newOwnerId; + this.roomName = roomName; + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/event/studyroom/StudyRoomNoticeCreatedEvent.java b/src/main/java/com/back/domain/notification/event/studyroom/StudyRoomNoticeCreatedEvent.java new file mode 100644 index 00000000..2a65e751 --- /dev/null +++ b/src/main/java/com/back/domain/notification/event/studyroom/StudyRoomNoticeCreatedEvent.java @@ -0,0 +1,26 @@ +package com.back.domain.notification.event.studyroom; + +import lombok.Getter; + +@Getter +public class StudyRoomNoticeCreatedEvent extends StudyRoomNotificationEvent { + private final String noticeTitle; + private final String noticeContent; + + public StudyRoomNoticeCreatedEvent(Long actorId, Long studyRoomId, String noticeTitle, String noticeContent) { + super( + actorId, + studyRoomId, + "새로운 공지사항", + "새로운 공지사항이 등록되었습니다" + ); + this.noticeTitle = noticeTitle; + this.noticeContent = noticeContent; + } + + public String getContentPreview() { + return noticeContent != null && noticeContent.length() > 50 + ? noticeContent.substring(0, 50) + "..." + : noticeContent; + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/event/studyroom/StudyRoomNotificationEvent.java b/src/main/java/com/back/domain/notification/event/studyroom/StudyRoomNotificationEvent.java new file mode 100644 index 00000000..40f59ee1 --- /dev/null +++ b/src/main/java/com/back/domain/notification/event/studyroom/StudyRoomNotificationEvent.java @@ -0,0 +1,20 @@ +package com.back.domain.notification.event.studyroom; + +import com.back.domain.notification.entity.NotificationType; +import lombok.Getter; + +@Getter +public abstract class StudyRoomNotificationEvent { + private final Long actorId; // 발신자 + private final Long studyRoomId; // 스터디룸 ID + private final NotificationType targetType = NotificationType.ROOM; + private final String title; + private final String content; + + protected StudyRoomNotificationEvent(Long actorId, Long studyRoomId, String title, String content) { + this.actorId = actorId; + this.studyRoomId = studyRoomId; + this.title = title; + this.content = content; + } +} diff --git a/src/main/java/com/back/domain/notification/event/studyroom/StudyRoomNotificationEventListener.java b/src/main/java/com/back/domain/notification/event/studyroom/StudyRoomNotificationEventListener.java new file mode 100644 index 00000000..7d253c0a --- /dev/null +++ b/src/main/java/com/back/domain/notification/event/studyroom/StudyRoomNotificationEventListener.java @@ -0,0 +1,149 @@ +package com.back.domain.notification.event.studyroom; + +import com.back.domain.notification.service.NotificationService; +import com.back.domain.studyroom.entity.Room; +import com.back.domain.studyroom.repository.RoomMemberRepository; +import com.back.domain.studyroom.repository.RoomRepository; +import com.back.domain.user.entity.User; +import com.back.domain.user.repository.UserRepository; +import com.back.global.exception.CustomException; +import com.back.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class StudyRoomNotificationEventListener { + + private final NotificationService notificationService; + private final RoomRepository roomRepository; + private final UserRepository userRepository; + private final RoomMemberRepository roomMemberRepository; + + // 스터디룸 공지사항 등록 시 - 전체 멤버에게 알림 + @EventListener + @Async("notificationExecutor") + public void handleNoticeCreated(StudyRoomNoticeCreatedEvent event) { + log.info("[알림] 스터디룸 공지사항 등록: roomId={}, actorId={}", + event.getStudyRoomId(), event.getActorId()); + + try { + // Room 조회 + Room room = roomRepository.findById(event.getStudyRoomId()) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + + // Actor (공지 작성자) 조회 + User actor = userRepository.findById(event.getActorId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // 스터디룸 알림 생성 + notificationService.createRoomNotification( + room, + actor, + event.getTitle(), + event.getNoticeTitle(), // content에 공지 제목 + "/rooms/" + event.getStudyRoomId() + "/notices" + ); + + log.info("[알림] 스터디룸 공지사항 알림 전송 완료"); + + } catch (Exception e) { + log.error("[알림] 스터디룸 공지사항 알림 전송 실패: roomId={}, error={}", + event.getStudyRoomId(), e.getMessage(), e); + } + } + + // 권한 변경 시 - 해당 유저에게만 알림 + @EventListener + @Async("notificationExecutor") + public void handleMemberRoleChanged(MemberRoleChangedEvent event) { + log.info("[알림] 멤버 권한 변경: roomId={}, targetUserId={}, newRole={}", + event.getStudyRoomId(), event.getTargetUserId(), event.getNewRole()); + + try { + // 수신자 조회 + User receiver = userRepository.findById(event.getTargetUserId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // 발신자 조회 + User actor = userRepository.findById(event.getActorId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + // 개인 알림 생성 + notificationService.createPersonalNotification( + receiver, + actor, + event.getTitle(), + event.getContent(), + "/rooms/" + event.getStudyRoomId() + ); + + log.info("[알림] 권한 변경 알림 전송 완료"); + + } catch (Exception e) { + log.error("[알림] 권한 변경 알림 전송 실패: error={}", e.getMessage(), e); + } + } + + // 멤버 추방 시 - 해당 유저에게만 알림 + @EventListener + @Async("notificationExecutor") + public void handleMemberKicked(MemberKickedEvent event) { + log.info("[알림] 멤버 추방: roomId={}, targetUserId={}", + event.getStudyRoomId(), event.getTargetUserId()); + + try { + User receiver = userRepository.findById(event.getTargetUserId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + User actor = userRepository.findById(event.getActorId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + notificationService.createPersonalNotification( + receiver, + actor, + event.getTitle(), + event.getContent(), + "/rooms" + ); + + log.info("[알림] 멤버 추방 알림 전송 완료"); + + } catch (Exception e) { + log.error("[알림] 멤버 추방 알림 전송 실패: error={}", e.getMessage(), e); + } + } + + // 방장 위임 시 - 새 방장에게만 알림 + @EventListener + @Async("notificationExecutor") + public void handleOwnerTransferred(OwnerTransferredEvent event) { + log.info("[알림] 방장 위임: roomId={}, newOwnerId={}", + event.getStudyRoomId(), event.getNewOwnerId()); + + try { + User receiver = userRepository.findById(event.getNewOwnerId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + User actor = userRepository.findById(event.getActorId()) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); + + notificationService.createPersonalNotification( + receiver, + actor, + event.getTitle(), + event.getContent(), + "/rooms/" + event.getStudyRoomId() + ); + + log.info("[알림] 방장 위임 알림 전송 완료"); + + } catch (Exception e) { + log.error("[알림] 방장 위임 알림 전송 실패: error={}", e.getMessage(), e); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/repository/NotificationRepositoryCustom.java b/src/main/java/com/back/domain/notification/repository/NotificationRepositoryCustom.java index 1b7b6cb0..5568770c 100644 --- a/src/main/java/com/back/domain/notification/repository/NotificationRepositoryCustom.java +++ b/src/main/java/com/back/domain/notification/repository/NotificationRepositoryCustom.java @@ -16,4 +16,7 @@ public interface NotificationRepositoryCustom { // 특정 유저의 읽지 않은 알림 목록 조회 Page findUnreadByUserId(Long userId, Pageable pageable); + + // 특정 유저의 읽지 않은 알림 전체 조회 + List findAllUnreadByUserId(Long userId); } diff --git a/src/main/java/com/back/domain/notification/repository/NotificationRepositoryImpl.java b/src/main/java/com/back/domain/notification/repository/NotificationRepositoryImpl.java index 01397df4..62ce760a 100644 --- a/src/main/java/com/back/domain/notification/repository/NotificationRepositoryImpl.java +++ b/src/main/java/com/back/domain/notification/repository/NotificationRepositoryImpl.java @@ -143,4 +143,28 @@ public Page findUnreadByUserId(Long userId, Pageable pageable) { return new PageImpl<>(content, pageable, total != null ? total : 0L); } + + @Override + public List findAllUnreadByUserId(Long userId) { + QNotification notification = QNotification.notification; + QNotificationRead notificationRead = QNotificationRead.notificationRead; + QRoomMember roomMember = QRoomMember.roomMember; + + return queryFactory + .selectFrom(notification) + .leftJoin(notificationRead) + .on(notification.id.eq(notificationRead.notification.id) + .and(notificationRead.user.id.eq(userId))) + .leftJoin(roomMember) + .on(notification.room.id.eq(roomMember.room.id) + .and(roomMember.user.id.eq(userId))) + .where( + notification.receiver.id.eq(userId) + .or(notification.type.eq(NotificationType.SYSTEM)) + .or(roomMember.id.isNotNull()), + notificationRead.id.isNull() + ) + .orderBy(notification.createdAt.desc()) + .fetch(); + } } \ No newline at end of file diff --git a/src/main/java/com/back/domain/notification/service/NotificationService.java b/src/main/java/com/back/domain/notification/service/NotificationService.java index b95e13fd..cd26a5d4 100644 --- a/src/main/java/com/back/domain/notification/service/NotificationService.java +++ b/src/main/java/com/back/domain/notification/service/NotificationService.java @@ -16,6 +16,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Slf4j @Service @RequiredArgsConstructor @@ -176,17 +178,20 @@ public void markAsRead(Long notificationId, User user) { // 여러 알림 일괄 읽음 처리 @Transactional public void markMultipleAsRead(Long userId, User user) { - Page unreadNotifications = getUnreadNotifications(userId, Pageable.unpaged()); - - int count = 0; - for (Notification notification : unreadNotifications) { - if (!notificationReadRepository.existsByNotificationIdAndUserId(notification.getId(), user.getId())) { - NotificationRead notificationRead = NotificationRead.create(notification, user); - notificationReadRepository.save(notificationRead); - count++; - } + // Page가 아닌 List로 직접 조회 + List unreadNotifications = notificationRepository.findAllUnreadByUserId(userId); + + List notificationReads = unreadNotifications.stream() + .filter(notification -> !notificationReadRepository + .existsByNotificationIdAndUserId(notification.getId(), user.getId())) + .map(notification -> NotificationRead.create(notification, user)) + .toList(); + + if (!notificationReads.isEmpty()) { + notificationReadRepository.saveAll(notificationReads); + log.info("일괄 읽음 처리 - 유저 ID: {}, 처리 개수: {}", userId, notificationReads.size()); + } else { + log.info("일괄 읽음 처리 - 유저 ID: {}, 읽을 알림 없음", userId); } - - log.info("일괄 읽음 처리 - 유저 ID: {}, 처리 개수: {}", userId, count); } } \ No newline at end of file diff --git a/src/main/java/com/back/domain/study/record/repository/StudyRecordRepository.java b/src/main/java/com/back/domain/study/record/repository/StudyRecordRepository.java index fdac8fd2..1eef066d 100644 --- a/src/main/java/com/back/domain/study/record/repository/StudyRecordRepository.java +++ b/src/main/java/com/back/domain/study/record/repository/StudyRecordRepository.java @@ -28,4 +28,16 @@ List findByUserIdAndDateRange( @Param("userId") Long userId, @Param("startOfDay") LocalDateTime startOfDay, @Param("endOfDay") LocalDateTime endOfDay); + + // 특정 StudyPlan에 대해 특정 날짜에 StudyRecord가 있는지 확인 (일일 목표 달성 알림 체크용) + @Query("SELECT CASE WHEN COUNT(sr) > 0 THEN true ELSE false END " + + "FROM StudyRecord sr " + + "WHERE sr.studyPlan.id = :planId " + + "AND sr.startTime >= :startOfDay " + + "AND sr.startTime < :endOfDay") + boolean existsByStudyPlanIdAndDate( + @Param("planId") Long planId, + @Param("startOfDay") LocalDateTime startOfDay, + @Param("endOfDay") LocalDateTime endOfDay + ); } diff --git a/src/main/java/com/back/domain/study/record/service/StudyRecordService.java b/src/main/java/com/back/domain/study/record/service/StudyRecordService.java index 008985e3..9958fc97 100644 --- a/src/main/java/com/back/domain/study/record/service/StudyRecordService.java +++ b/src/main/java/com/back/domain/study/record/service/StudyRecordService.java @@ -1,7 +1,11 @@ package com.back.domain.study.record.service; +import com.back.domain.notification.event.study.DailyGoalAchievedEvent; +import com.back.domain.notification.event.study.StudyRecordCreatedEvent; +import com.back.domain.study.plan.dto.StudyPlanResponse; import com.back.domain.study.plan.entity.StudyPlan; import com.back.domain.study.plan.repository.StudyPlanRepository; +import com.back.domain.study.plan.service.StudyPlanService; import com.back.domain.study.record.dto.StudyRecordRequestDto; import com.back.domain.study.record.dto.StudyRecordResponseDto; import com.back.domain.study.record.entity.PauseInfo; @@ -14,21 +18,27 @@ import com.back.global.exception.CustomException; import com.back.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor public class StudyRecordService { + private final StudyPlanService studyPlanService; private final StudyRecordRepository studyRecordRepository; private final StudyPlanRepository studyPlanRepository; private final RoomRepository roomRepository; private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; // ===================== 생성 ===================== // 학습 기록 생성 (종료 시 한 번에 기록) @@ -91,8 +101,23 @@ public StudyRecordResponseDto createStudyRecord(Long userId, StudyRecordRequestD // 저장 StudyRecord saved = studyRecordRepository.save(record); + + // 학습 기록 등록 이벤트 발행 + eventPublisher.publishEvent( + new StudyRecordCreatedEvent( + userId, + saved.getId(), + saved.getStudyPlan().getId(), + saved.getDuration() + ) + ); + + // 일일 목표 달성 여부 체크 후 이벤트 발행 + checkAndNotifyDailyGoalAchievement(userId, saved.getStartTime().toLocalDate()); + return StudyRecordResponseDto.from(saved); } + // ===================== 조회 ===================== // 날짜별 학습 기록 조회 public List getStudyRecordsByDate(Long userId, LocalDate date) { @@ -113,6 +138,66 @@ public List getStudyRecordsByDate(Long userId, LocalDate .collect(Collectors.toList()); } + // ===================== 이벤트 체크 ===================== + // 일일 목표 달성 여부 체크 + private void checkAndNotifyDailyGoalAchievement(Long userId, LocalDate date) { + try { + // 오늘의 학습 계획 조회 + List todayPlans = getTodayStudyPlans(userId, date); + + if (todayPlans.isEmpty()) { + return; + } + + // 오늘 완료한 계획 개수 + int completedCount = 0; + LocalDateTime startOfDay = date.atTime(4, 0); + LocalDateTime endOfDay = date.plusDays(1).atTime(4, 0); + + for (StudyPlan plan : todayPlans) { + boolean hasRecord = studyRecordRepository.existsByStudyPlanIdAndDate( + plan.getId(), + startOfDay, + endOfDay + ); + if (hasRecord) { + completedCount++; + } + } + + // 모든 계획 완료 시 이벤트 발행 + if (completedCount == todayPlans.size()) { + eventPublisher.publishEvent( + new DailyGoalAchievedEvent( + userId, + date, + completedCount, + todayPlans.size() + ) + ); + + log.info("일일 목표 달성 이벤트 발행 - userId: {}, date: {}", userId, date); + } + + } catch (Exception e) { + log.error("일일 목표 체크 실패 - userId: {}, error: {}", userId, e.getMessage()); + } + } + + // 오늘의 학습 계획 조회 + private List getTodayStudyPlans(Long userId, LocalDate date) { + + List planResponses = studyPlanService.getStudyPlansForDate(userId, date); + + // StudyPlanResponse에서 planId 추출하여 실제 엔티티 조회 + List planIds = planResponses.stream() + .map(StudyPlanResponse::getId) + .distinct() + .collect(Collectors.toList()); + + return studyPlanRepository.findAllById(planIds); + } + // ===================== 유틸 ===================== // 시간 범위 검증 private void validateTimeRange(java.time.LocalDateTime startTime, java.time.LocalDateTime endTime) { diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java index 399c3254..0f623cfa 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryCustom.java @@ -93,4 +93,9 @@ public interface RoomMemberRepositoryCustom { */ @Deprecated void disconnectAllMembers(Long roomId); + + /** + * 스터디룸의 모든 멤버 User ID 조회 (알림 전송용) + */ + List findUserIdsByRoomId(Long roomId); } diff --git a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java index c48f2aa6..6d899650 100644 --- a/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java +++ b/src/main/java/com/back/domain/studyroom/repository/RoomMemberRepositoryImpl.java @@ -331,4 +331,20 @@ public void leaveRoom(Long roomId, Long userId) { public void disconnectAllMembers(Long roomId) { // Redis로 이관 예정 - 현재는 아무 동작 안함 } + + /** + * 스터디룸의 모든 멤버 User ID 조회 (알림 전송용) + * - 알림 대상자 조회 + * - N+1 방지를 위해 User ID만 조회 + * @param roomId 방 ID + * @return 멤버들의 User ID 목록 + */ + @Override + public List findUserIdsByRoomId(Long roomId) { + return queryFactory + .select(roomMember.user.id) + .from(roomMember) + .where(roomMember.room.id.eq(roomId)) + .fetch(); + } } diff --git a/src/main/java/com/back/domain/studyroom/service/RoomService.java b/src/main/java/com/back/domain/studyroom/service/RoomService.java index f6d6db64..3065ead9 100644 --- a/src/main/java/com/back/domain/studyroom/service/RoomService.java +++ b/src/main/java/com/back/domain/studyroom/service/RoomService.java @@ -1,5 +1,8 @@ package com.back.domain.studyroom.service; +import com.back.domain.notification.event.studyroom.MemberKickedEvent; +import com.back.domain.notification.event.studyroom.MemberRoleChangedEvent; +import com.back.domain.notification.event.studyroom.OwnerTransferredEvent; import com.back.domain.studyroom.config.StudyRoomProperties; import com.back.domain.studyroom.dto.RoomResponse; import com.back.domain.studyroom.entity.*; @@ -11,8 +14,10 @@ import com.back.global.websocket.service.RoomParticipantService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -44,7 +49,8 @@ public class RoomService { private final UserRepository userRepository; private final StudyRoomProperties properties; private final RoomParticipantService roomParticipantService; - private final org.springframework.messaging.simp.SimpMessagingTemplate messagingTemplate; + private final SimpMessagingTemplate messagingTemplate; + private final ApplicationEventPublisher eventPublisher; /** * 방 생성 메서드 @@ -384,6 +390,19 @@ public void changeUserRole(Long roomId, Long targetUserId, RoomRole newRole, Lon // 기존 방장을 MEMBER로 강등 requester.updateRole(RoomRole.MEMBER); log.info("기존 방장 강등 - RoomId: {}, UserId: {}, MEMBER로 변경", roomId, requesterId); + + // 방장 위임 이벤트 발행 + Room room = roomRepository.findById(roomId) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + + eventPublisher.publishEvent( + new OwnerTransferredEvent( + requesterId, // 이전 방장 + roomId, + targetUserId, // 새 방장 + room.getTitle() + ) + ); } // 5. 대상자 처리 @@ -409,6 +428,18 @@ public void changeUserRole(Long roomId, Long targetUserId, RoomRole newRole, Lon log.info("VISITOR 승격 (DB 저장) - RoomId: {}, UserId: {}, NewRole: {}", roomId, targetUserId, newRole); } + + // 권한 변경 이벤트 발행 (HOST 위임이 아닌 경우만) + if (newRole != RoomRole.HOST) { + eventPublisher.publishEvent( + new MemberRoleChangedEvent( + requesterId, + roomId, + targetUserId, + newRole.name() + ) + ); + } // 6. WebSocket으로 역할 변경 알림 브로드캐스트 User targetUser = userRepository.findById(targetUserId) @@ -538,6 +569,19 @@ public void kickMember(Long roomId, Long targetUserId, Long requesterId) { // Redis에서 제거 (강제 퇴장) roomParticipantService.exitRoom(targetUserId, roomId); + + // 멤버 추방 이벤트 발행 + Room room = roomRepository.findById(roomId) + .orElseThrow(() -> new CustomException(ErrorCode.ROOM_NOT_FOUND)); + + eventPublisher.publishEvent( + new MemberKickedEvent( + requesterId, + roomId, + targetUserId, + room.getTitle() + ) + ); log.info("멤버 추방 완료 - RoomId: {}, TargetUserId: {}, RequesterId: {}", roomId, targetUserId, requesterId); diff --git a/src/main/java/com/back/global/config/AsyncConfig.java b/src/main/java/com/back/global/config/AsyncConfig.java new file mode 100644 index 00000000..9570ed19 --- /dev/null +++ b/src/main/java/com/back/global/config/AsyncConfig.java @@ -0,0 +1,26 @@ +package com.back.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean(name = "notificationExecutor") + public Executor notificationExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); // 기본 스레드 수 + executor.setMaxPoolSize(10); // 최대 스레드 수 + executor.setQueueCapacity(100); // 큐 용량 + executor.setThreadNamePrefix("notification-async-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(60); + executor.initialize(); + return executor; + } +} diff --git a/src/test/java/com/back/domain/notification/repository/NotificationRepositoryTest.java b/src/test/java/com/back/domain/notification/repository/NotificationRepositoryTest.java index 88439248..333fe7fa 100644 --- a/src/test/java/com/back/domain/notification/repository/NotificationRepositoryTest.java +++ b/src/test/java/com/back/domain/notification/repository/NotificationRepositoryTest.java @@ -503,4 +503,84 @@ void t2() { .containsOnly(NotificationType.SYSTEM); } } + + @Nested + @DisplayName("findAllUnreadByUserId 테스트") + class FindAllUnreadByUserIdTest { + + @Test + @DisplayName("읽지 않은 알림 전체 조회 (페이징 없음)") + void t1() { + // given + Notification unread1 = Notification.createPersonalNotification( + user1, actor, "읽지 않음1", "내용", "/1" + ); + Notification unread2 = Notification.createPersonalNotification( + user1, actor, "읽지 않음2", "내용", "/2" + ); + Notification read = Notification.createPersonalNotification( + user1, actor, "읽음", "내용", "/read" + ); + notificationRepository.saveAll(List.of(unread1, unread2, read)); + + // read 알림을 읽음 처리 + NotificationRead notificationRead = NotificationRead.create(read, user1); + notificationReadRepository.save(notificationRead); + + // when + List result = notificationRepository + .findAllUnreadByUserId(user1.getId()); + + // then + assertThat(result).hasSize(2); + assertThat(result) + .extracting(Notification::getTitle) + .containsExactlyInAnyOrder("읽지 않음1", "읽지 않음2"); + } + + @Test + @DisplayName("모든 알림을 읽은 경우 빈 리스트 반환") + void t2() { + // given + Notification notification = Notification.createPersonalNotification( + user1, actor, "읽음", "내용", "/read" + ); + notificationRepository.save(notification); + + NotificationRead read = NotificationRead.create(notification, user1); + notificationReadRepository.save(read); + + // when + List result = notificationRepository + .findAllUnreadByUserId(user1.getId()); + + // then + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("생성일시 내림차순으로 정렬") + void t3() throws InterruptedException { + // given + Notification old = Notification.createPersonalNotification( + user1, actor, "오래된", "내용", "/old" + ); + notificationRepository.save(old); + + Thread.sleep(100); + + Notification recent = Notification.createPersonalNotification( + user1, actor, "최근", "내용", "/recent" + ); + notificationRepository.save(recent); + + // when + List result = notificationRepository + .findAllUnreadByUserId(user1.getId()); + + // then + assertThat(result.get(0).getTitle()).isEqualTo("최근"); + assertThat(result.get(1).getTitle()).isEqualTo("오래된"); + } + } } \ No newline at end of file diff --git a/src/test/java/com/back/domain/notification/service/NotificationServiceTest.java b/src/test/java/com/back/domain/notification/service/NotificationServiceTest.java index 58f263b9..cb08c784 100644 --- a/src/test/java/com/back/domain/notification/service/NotificationServiceTest.java +++ b/src/test/java/com/back/domain/notification/service/NotificationServiceTest.java @@ -401,12 +401,10 @@ void t4() { ); ReflectionTestUtils.setField(notification2, "id", 2L); - Page unreadPage = new PageImpl<>( - List.of(notification, notification2) - ); + List unreadList = List.of(notification, notification2); - given(notificationRepository.findUnreadByUserId(user.getId(), Pageable.unpaged())) - .willReturn(unreadPage); + given(notificationRepository.findAllUnreadByUserId(user.getId())) + .willReturn(unreadList); given(notificationReadRepository.existsByNotificationIdAndUserId(anyLong(), eq(user.getId()))) .willReturn(false); @@ -414,8 +412,10 @@ void t4() { notificationService.markMultipleAsRead(user.getId(), user); // then - verify(notificationRepository).findUnreadByUserId(user.getId(), Pageable.unpaged()); - verify(notificationReadRepository, times(2)).save(any(NotificationRead.class)); + verify(notificationRepository).findAllUnreadByUserId(user.getId()); + verify(notificationReadRepository).saveAll(argThat(list -> + list != null && ((List) list).size() == 2 + )); } @Test @@ -427,23 +427,23 @@ void t5() { ); ReflectionTestUtils.setField(notification2, "id", 2L); - Page unreadPage = new PageImpl<>( - List.of(notification, notification2) - ); + List unreadList = List.of(notification, notification2); - given(notificationRepository.findUnreadByUserId(user.getId(), Pageable.unpaged())) - .willReturn(unreadPage); + given(notificationRepository.findAllUnreadByUserId(user.getId())) + .willReturn(unreadList); given(notificationReadRepository.existsByNotificationIdAndUserId(1L, user.getId())) - .willReturn(true); // 첫 번째 알림은 이미 읽음 + .willReturn(true); given(notificationReadRepository.existsByNotificationIdAndUserId(2L, user.getId())) - .willReturn(false); // 두 번째 알림은 안 읽음 + .willReturn(false); // when notificationService.markMultipleAsRead(user.getId(), user); // then - verify(notificationRepository).findUnreadByUserId(user.getId(), Pageable.unpaged()); - verify(notificationReadRepository, times(1)).save(any(NotificationRead.class)); + verify(notificationRepository).findAllUnreadByUserId(user.getId()); + verify(notificationReadRepository).saveAll(argThat(list -> + list != null && ((List) list).size() == 1 + )); } } } \ No newline at end of file diff --git a/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java b/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java index b0f9cb7e..0ebb296f 100644 --- a/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java +++ b/src/test/java/com/back/domain/studyroom/service/RoomServiceTest.java @@ -19,6 +19,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -51,6 +52,9 @@ class RoomServiceTest { @Mock private RoomParticipantService roomParticipantService; + @Mock + private ApplicationEventPublisher eventPublisher; + @InjectMocks private RoomService roomService; @@ -374,6 +378,7 @@ void kickMember_Success() { given(roomMemberRepository.findByRoomIdAndUserId(1L, 1L)).willReturn(Optional.of(hostMember)); given(roomMemberRepository.findByRoomIdAndUserId(1L, 2L)).willReturn(Optional.of(targetMember)); + given(roomRepository.findById(1L)).willReturn(Optional.of(testRoom)); // when roomService.kickMember(1L, 2L, 1L);