Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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로 보낸 메세지를 받는 주소가 여기
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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")
Expand Down Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Comment on lines +35 to +37
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

joinRequest.getClub() 또는 joinRequest.getUser()가 null일 경우 NPE 발생 가능

joinRequest != null 검사만으로는 체이닝된 .getClub().getName(), .getUser().getNickname() 호출의 안전성을 보장하지 못합니다. 연관 엔티티가 null이면 NullPointerException이 발생합니다.

Optional을 사용하면 null 체크 반복도 줄이고 체이닝 안전성도 확보할 수 있습니다.

🛡️ Optional을 활용한 수정 제안
   public static NotificationResponse from(Notification notification) {
-    var joinRequest = notification.getJoinClubRequest();
-
+    var joinRequest = Optional.ofNullable(notification.getJoinClubRequest());
+
     return NotificationResponse.builder()
         .id(notification.getId())
         .type(notification.getType())
         .isRead(notification.getIsRead())
-        .joinRequestId(joinRequest != null ? joinRequest.getId() : null)
-        .clubName(joinRequest != null ? joinRequest.getClub().getName() : null)
-        .applicantNickname(joinRequest != null ? joinRequest.getUser().getNickname() : null)
+        .joinRequestId(joinRequest.map(jr -> jr.getId()).orElse(null))
+        .clubName(joinRequest.map(jr -> jr.getClub()).map(club -> club.getName()).orElse(null))
+        .applicantNickname(joinRequest.map(jr -> jr.getUser()).map(user -> user.getNickname()).orElse(null))
         .targetId(notification.getTargetId())
         .createdAt(notification.getCreatedAt())
         .build();
   }

java.util.Optional import도 추가해야 합니다:

import java.util.Optional;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.joinRequestId(joinRequest != null ? joinRequest.getId() : null)
.clubName(joinRequest != null ? joinRequest.getClub().getName() : null)
.applicantNickname(joinRequest != null ? joinRequest.getUser().getNickname() : null)
import java.util.Optional;
public static NotificationResponse from(Notification notification) {
var joinRequest = Optional.ofNullable(notification.getJoinClubRequest());
return NotificationResponse.builder()
.id(notification.getId())
.type(notification.getType())
.isRead(notification.getIsRead())
.joinRequestId(joinRequest.map(jr -> jr.getId()).orElse(null))
.clubName(joinRequest.map(jr -> jr.getClub()).map(club -> club.getName()).orElse(null))
.applicantNickname(joinRequest.map(jr -> jr.getUser()).map(user -> user.getNickname()).orElse(null))
.targetId(notification.getTargetId())
.createdAt(notification.getCreatedAt())
.build();
}
🤖 Prompt for AI Agents
In
`@src/main/java/com/be/sportizebe/domain/notification/dto/response/NotificationResponse.java`
around lines 28 - 30, The builder chain in NotificationResponse (the lines
calling .clubName(...) and .applicantNickname(...) on joinRequest) can NPE if
joinRequest.getClub() or joinRequest.getUser() is null; update the expressions
to safely handle those cases (either by adding explicit null checks for
joinRequest.getClub() and joinRequest.getUser() before calling
getName()/getNickname(), or use Optional.ofNullable(joinRequest).map(j ->
j.getClub()).map(Club::getName).orElse(null) and similarly for the user
nickname) so the builder receives null when the related entity is missing rather
than throwing an exception; add import java.util.Optional if you choose the
Optional approach.

.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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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.*;
Expand All @@ -24,7 +26,9 @@ public enum NotificationType {
JOIN_APPROVED, // 가입 승인 (신청자에게)
JOIN_REJECTED, // 가입 거절 (신청자에게)
CHAT, // 새 채팅 메시지
COMMENT // 새 댓글
COMMENT, // 새 댓글 (게시글 작성자에게)
REPLY, // 대댓글 (부모 댓글 작성자에게)
NOTE // 쪽지 (수신자에게)
}

@Id
Expand All @@ -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;
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<NotificationResponse> getNotifications(User user);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -24,62 +26,43 @@ 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();

notificationRepository.save(notification);

// 웹소켓으로 실시간 알림 전송
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
Expand Down Expand Up @@ -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);
}

/**
* 웹소켓으로 알림 전송
*/
Expand Down