diff --git a/src/main/java/com/be/sportizebe/domain/chat/websocket/handler/ChatStompController.java b/src/main/java/com/be/sportizebe/domain/chat/websocket/handler/ChatStompController.java index 0953d85..86f78c5 100644 --- a/src/main/java/com/be/sportizebe/domain/chat/websocket/handler/ChatStompController.java +++ b/src/main/java/com/be/sportizebe/domain/chat/websocket/handler/ChatStompController.java @@ -5,8 +5,13 @@ import com.be.sportizebe.domain.chat.dto.request.ChatSendRequest; import com.be.sportizebe.domain.chat.entity.ChatMessage; import com.be.sportizebe.domain.chat.entity.ChatRoom; +import com.be.sportizebe.domain.chat.entity.ChatRoomMember; +import com.be.sportizebe.domain.chat.repository.ChatRoomMemberRepository; import com.be.sportizebe.domain.chat.service.ChatMessageService; import com.be.sportizebe.domain.chat.service.ChatRoomService; +import com.be.sportizebe.domain.notification.service.NotificationService; +import com.be.sportizebe.domain.user.entity.User; +import com.be.sportizebe.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.simp.SimpMessageHeaderAccessor; @@ -17,9 +22,12 @@ @RequiredArgsConstructor public class ChatStompController { private final ChatMessageService chatMessageService; - private final ChatRoomService chatRoomService; // ✅ 추가 + private final ChatRoomService chatRoomService; private final SimpMessagingTemplate messagingTemplate; private final ChatSessionRegistry registry; + private final NotificationService notificationService; + private final ChatRoomMemberRepository chatRoomMemberRepository; + private final UserRepository userRepository; /** * 클라이언트가 SEND로 보낸 메세지를 받는 주소가 여기 @@ -45,6 +53,24 @@ public void send(ChatSendRequest req){ "/sub/chat/rooms/" + req.getRoomId(), // 채팅방 단위 브로드캐스트 ChatMessageResponse.from(saved) ); + + // 1:1 채팅방(쪽지)인 경우 상대방에게 알림 전송 + if (room.getChatRoomType() == ChatRoom.ChatRoomType.NOTE) { + sendNoteNotification(saved, req.getSenderUserId()); + } + } + + /** + * 쪽지 알림 전송 (발신자 제외한 상대방에게) + */ + private void sendNoteNotification(ChatMessage chatMessage, Long senderUserId) { + chatRoomMemberRepository.findAllByRoom_IdAndLeftAtIsNull(chatMessage.getRoom().getId()) + .stream() + .map(ChatRoomMember::getUserId) + .filter(userId -> !userId.equals(senderUserId)) + .findFirst() // 첫번째 사람만 선택 + .flatMap(userRepository::findById) + .ifPresent(receiver -> notificationService.createNoteNotification(chatMessage, receiver)); } @MessageMapping("/chat.join") diff --git a/src/main/java/com/be/sportizebe/domain/club/entity/Club.java b/src/main/java/com/be/sportizebe/domain/club/entity/Club.java index a62d0ff..cb0f630 100644 --- a/src/main/java/com/be/sportizebe/domain/club/entity/Club.java +++ b/src/main/java/com/be/sportizebe/domain/club/entity/Club.java @@ -50,7 +50,7 @@ public class Club extends BaseTimeEntity { * 동호회장(LEADER) 조회 * ClubMember에서 LEADER 역할을 가진 멤버를 찾아 반환 */ - public ClubMember getLeaderMember() { + private ClubMember getLeaderMember() { return members.stream() .filter(member -> member.getRole() == ClubMember.ClubRole.LEADER) .findFirst() diff --git a/src/main/java/com/be/sportizebe/domain/comment/service/CommentServiceImpl.java b/src/main/java/com/be/sportizebe/domain/comment/service/CommentServiceImpl.java index 81a4347..a044a7a 100644 --- a/src/main/java/com/be/sportizebe/domain/comment/service/CommentServiceImpl.java +++ b/src/main/java/com/be/sportizebe/domain/comment/service/CommentServiceImpl.java @@ -6,6 +6,7 @@ import com.be.sportizebe.domain.comment.entity.Comment; import com.be.sportizebe.domain.comment.exception.CommentErrorCode; import com.be.sportizebe.domain.comment.repository.CommentRepository; +import com.be.sportizebe.domain.notification.service.NotificationService; import com.be.sportizebe.domain.post.entity.Post; import com.be.sportizebe.domain.post.exception.PostErrorCode; import com.be.sportizebe.domain.post.repository.PostRepository; @@ -30,6 +31,7 @@ public class CommentServiceImpl implements CommentService { private final CommentRepository commentRepository; private final PostRepository postRepository; private final UserRepository userRepository; + private final NotificationService notificationService; @Override @CacheEvict(cacheNames = {"commentList", "commentCount"}, key = "#postId") @@ -64,6 +66,15 @@ public CommentResponse createComment(Long postId, CreateCommentRequest request, Comment comment = request.toEntity(post, user, parent); Comment savedComment = commentRepository.save(comment); + // 알림 전송 + if (savedComment.getParent() != null) { + // 대댓글인 경우: 부모 댓글 작성자에게 알림 + notificationService.createReplyNotification(savedComment); + } else { + // 일반 댓글인 경우: 게시글 작성자에게 알림 + notificationService.createCommentNotification(savedComment); + } + return CommentResponse.from(savedComment); } diff --git a/src/main/java/com/be/sportizebe/domain/notification/dto/response/NotificationResponse.java b/src/main/java/com/be/sportizebe/domain/notification/dto/response/NotificationResponse.java index 33fc66a..75d5a1f 100644 --- a/src/main/java/com/be/sportizebe/domain/notification/dto/response/NotificationResponse.java +++ b/src/main/java/com/be/sportizebe/domain/notification/dto/response/NotificationResponse.java @@ -11,20 +11,35 @@ public record NotificationResponse( @Schema(description = "알림 ID") Long id, @Schema(description = "알림 타입") Notification.NotificationType type, - @Schema(description = "알림 메시지") String message, @Schema(description = "읽음 여부") Boolean isRead, @Schema(description = "관련 가입 신청 ID") Long joinRequestId, - @Schema(description = "관련 대상 ID (댓글, 채팅 등)") Long targetId, + @Schema(description = "동호회 이름") String clubName, + @Schema(description = "신청자 닉네임") String applicantNickname, + @Schema(description = "게시글 ID") Long postId, + @Schema(description = "게시글 제목") String postTitle, + @Schema(description = "댓글 작성자 닉네임") String commenterNickname, + @Schema(description = "채팅방 ID") Long chatRoomId, + @Schema(description = "쪽지 발신자 닉네임") String senderNickname, + @Schema(description = "관련 대상 ID") Long targetId, @Schema(description = "알림 생성 일시") LocalDateTime createdAt ) { public static NotificationResponse from(Notification notification) { + var joinRequest = notification.getJoinClubRequest(); + var comment = notification.getComment(); + var chatMessage = notification.getChatMessage(); + return NotificationResponse.builder() .id(notification.getId()) .type(notification.getType()) - .message(notification.getMessage()) .isRead(notification.getIsRead()) - .joinRequestId(notification.getJoinClubRequest() != null - ? notification.getJoinClubRequest().getId() : null) + .joinRequestId(joinRequest != null ? joinRequest.getId() : null) + .clubName(joinRequest != null ? joinRequest.getClub().getName() : null) + .applicantNickname(joinRequest != null ? joinRequest.getUser().getNickname() : null) + .postId(comment != null ? comment.getPost().getId() : null) + .postTitle(comment != null ? comment.getPost().getTitle() : null) + .commenterNickname(comment != null ? comment.getUser().getNickname() : null) + .chatRoomId(chatMessage != null ? chatMessage.getRoom().getId() : null) + .senderNickname(chatMessage != null ? chatMessage.getSenderNickname() : null) .targetId(notification.getTargetId()) .createdAt(notification.getCreatedAt()) .build(); diff --git a/src/main/java/com/be/sportizebe/domain/notification/entity/Notification.java b/src/main/java/com/be/sportizebe/domain/notification/entity/Notification.java index c1c420e..a1cb671 100644 --- a/src/main/java/com/be/sportizebe/domain/notification/entity/Notification.java +++ b/src/main/java/com/be/sportizebe/domain/notification/entity/Notification.java @@ -1,5 +1,7 @@ package com.be.sportizebe.domain.notification.entity; +import com.be.sportizebe.domain.chat.entity.ChatMessage; +import com.be.sportizebe.domain.comment.entity.Comment; import com.be.sportizebe.domain.user.entity.User; import com.be.sportizebe.global.common.BaseTimeEntity; import jakarta.persistence.*; @@ -24,7 +26,9 @@ public enum NotificationType { JOIN_APPROVED, // 가입 승인 (신청자에게) JOIN_REJECTED, // 가입 거절 (신청자에게) CHAT, // 새 채팅 메시지 - COMMENT // 새 댓글 + COMMENT, // 새 댓글 (게시글 작성자에게) + REPLY, // 대댓글 (부모 댓글 작성자에게) + NOTE // 쪽지 (수신자에게) } @Id @@ -39,9 +43,6 @@ public enum NotificationType { @Column(nullable = false) private NotificationType type; - @Column(nullable = false) - private String message; // 알림 메시지 - @Column(nullable = false) @Builder.Default private Boolean isRead = false; @@ -51,8 +52,17 @@ public enum NotificationType { @JoinColumn(name = "join_request_id") private JoinClubRequest joinClubRequest; - // 댓글 알림용 - targetId와 targetType으로 다형성 처리 - // COMMENT: postId, CHAT: chatRoomId 등 + // 댓글 알림용 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "comment_id") + private Comment comment; + + // 쪽지 알림용 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_message_id") + private ChatMessage chatMessage; + + // 기타 알림용 - targetId로 다형성 처리 private Long targetId; public void markAsRead() { diff --git a/src/main/java/com/be/sportizebe/domain/notification/service/JoinClubRequestServiceImpl.java b/src/main/java/com/be/sportizebe/domain/notification/service/JoinClubRequestServiceImpl.java index 6729ad9..8265f28 100644 --- a/src/main/java/com/be/sportizebe/domain/notification/service/JoinClubRequestServiceImpl.java +++ b/src/main/java/com/be/sportizebe/domain/notification/service/JoinClubRequestServiceImpl.java @@ -66,7 +66,7 @@ public JoinClubRequestResponse requestJoin(Long clubId, Long userId) { joinClubRequestRepository.save(joinRequest); // 동호회장에게 알림 전송 - User leader = club.getLeader(); + User leader = club.getLeader(); // 사용자 관련 정보 추출을 위해 getLeader() 사용 if (leader != null) { notificationService.createJoinRequestNotification(joinRequest, leader); } diff --git a/src/main/java/com/be/sportizebe/domain/notification/service/NotificationService.java b/src/main/java/com/be/sportizebe/domain/notification/service/NotificationService.java index 0494ae8..1a558ad 100644 --- a/src/main/java/com/be/sportizebe/domain/notification/service/NotificationService.java +++ b/src/main/java/com/be/sportizebe/domain/notification/service/NotificationService.java @@ -1,5 +1,7 @@ package com.be.sportizebe.domain.notification.service; +import com.be.sportizebe.domain.chat.entity.ChatMessage; +import com.be.sportizebe.domain.comment.entity.Comment; import com.be.sportizebe.domain.notification.dto.response.NotificationResponse; import com.be.sportizebe.domain.notification.entity.JoinClubRequest; import com.be.sportizebe.domain.notification.entity.Notification; @@ -10,13 +12,22 @@ public interface NotificationService { // 가입 신청 알림 생성 및 웹소켓 전송 - Notification createJoinRequestNotification(JoinClubRequest joinRequest, User receiver); + void createJoinRequestNotification(JoinClubRequest joinRequest, User receiver); // 가입 승인 알림 생성 및 웹소켓 전송 - Notification createJoinApprovedNotification(JoinClubRequest joinRequest); + void createJoinApprovedNotification(JoinClubRequest joinRequest); // 가입 거절 알림 생성 및 웹소켓 전송 - Notification createJoinRejectedNotification(JoinClubRequest joinRequest); + void createJoinRejectedNotification(JoinClubRequest joinRequest); + + // 댓글 알림 생성 및 웹소켓 전송 (게시글 작성자에게) + void createCommentNotification(Comment comment); + + // 대댓글 알림 생성 및 웹소켓 전송 (부모 댓글 작성자에게) + void createReplyNotification(Comment reply); + + // 쪽지 알림 생성 및 웹소켓 전송 (수신자에게) + void createNoteNotification(ChatMessage chatMessage, User receiver); // 사용자의 모든 알림 조회 List getNotifications(User user); diff --git a/src/main/java/com/be/sportizebe/domain/notification/service/NotificationServiceImpl.java b/src/main/java/com/be/sportizebe/domain/notification/service/NotificationServiceImpl.java index 24ff39a..8ea1189 100644 --- a/src/main/java/com/be/sportizebe/domain/notification/service/NotificationServiceImpl.java +++ b/src/main/java/com/be/sportizebe/domain/notification/service/NotificationServiceImpl.java @@ -1,5 +1,7 @@ package com.be.sportizebe.domain.notification.service; +import com.be.sportizebe.domain.chat.entity.ChatMessage; +import com.be.sportizebe.domain.comment.entity.Comment; import com.be.sportizebe.domain.notification.dto.response.NotificationResponse; import com.be.sportizebe.domain.notification.entity.JoinClubRequest; import com.be.sportizebe.domain.notification.entity.Notification; @@ -24,15 +26,10 @@ public class NotificationServiceImpl implements NotificationService { @Override @Transactional - public Notification createJoinRequestNotification(JoinClubRequest joinRequest, User receiver) { - String message = String.format("%s님이 %s 동호회에 가입을 신청했습니다.", - joinRequest.getUser().getNickname(), - joinRequest.getClub().getName()); - + public void createJoinRequestNotification(JoinClubRequest joinRequest, User receiver) { Notification notification = Notification.builder() .receiver(receiver) .type(Notification.NotificationType.JOIN_REQUEST) - .message(message) .joinClubRequest(joinRequest) .build(); @@ -40,46 +37,32 @@ public Notification createJoinRequestNotification(JoinClubRequest joinRequest, U // 웹소켓으로 실시간 알림 전송 sendNotificationToUser(receiver.getId(), notification); - - return notification; } @Override @Transactional - public Notification createJoinApprovedNotification(JoinClubRequest joinRequest) { - String message = String.format("%s 동호회 가입이 승인되었습니다.", - joinRequest.getClub().getName()); - + public void createJoinApprovedNotification(JoinClubRequest joinRequest) { Notification notification = Notification.builder() .receiver(joinRequest.getUser()) .type(Notification.NotificationType.JOIN_APPROVED) - .message(message) .joinClubRequest(joinRequest) .build(); notificationRepository.save(notification); sendNotificationToUser(joinRequest.getUser().getId(), notification); - - return notification; } @Override @Transactional - public Notification createJoinRejectedNotification(JoinClubRequest joinRequest) { - String message = String.format("%s 동호회 가입이 거절되었습니다.", - joinRequest.getClub().getName()); - + public void createJoinRejectedNotification(JoinClubRequest joinRequest) { Notification notification = Notification.builder() .receiver(joinRequest.getUser()) .type(Notification.NotificationType.JOIN_REJECTED) - .message(message) .joinClubRequest(joinRequest) .build(); notificationRepository.save(notification); sendNotificationToUser(joinRequest.getUser().getId(), notification); - - return notification; } @Override @@ -116,6 +99,71 @@ public void markAsRead(Long notificationId, Long userId) { notification.markAsRead(); } + @Override + @Transactional + public void createCommentNotification(Comment comment) { + User postAuthor = comment.getPost().getUser(); + User commenter = comment.getUser(); + + // 자신의 게시글에 자신이 댓글을 단 경우 알림 생성하지 않음 + if (postAuthor.getId() == commenter.getId()) { + return; + } + + Notification notification = Notification.builder() + .receiver(postAuthor) + .type(Notification.NotificationType.COMMENT) + .comment(comment) + .build(); + + notificationRepository.save(notification); + sendNotificationToUser(postAuthor.getId(), notification); + } + + @Override + @Transactional + public void createReplyNotification(Comment reply) { + Comment parentComment = reply.getParent(); + if (parentComment == null) { + return; + } + + User parentAuthor = parentComment.getUser(); + User replier = reply.getUser(); + + // 자신의 댓글에 자신이 대댓글을 단 경우 알림 생성하지 않음 + if (parentAuthor.getId() == replier.getId()) { + return; + } + + Notification notification = Notification.builder() + .receiver(parentAuthor) + .type(Notification.NotificationType.REPLY) + .comment(reply) + .build(); + + notificationRepository.save(notification); + sendNotificationToUser(parentAuthor.getId(), notification); + } + + @Override + @Transactional + public void createNoteNotification(ChatMessage chatMessage, User receiver) { + // 자신에게 보낸 경우 알림 생성하지 않음 + if (receiver.getId() == chatMessage.getSenderUserId()) { + return; + } + + Notification notification = Notification.builder() + .receiver(receiver) + .type(Notification.NotificationType.NOTE) + .chatMessage(chatMessage) + .build(); + + notificationRepository.save(notification); + sendNotificationToUser(receiver.getId(), notification); + } + /** * 웹소켓으로 알림 전송 */