diff --git a/src/main/java/connectripbe/connectrip_be/comment/service/impl/AccompanyCommentServiceImpl.java b/src/main/java/connectripbe/connectrip_be/comment/service/impl/AccompanyCommentServiceImpl.java index 379dc4b0..7a08b633 100644 --- a/src/main/java/connectripbe/connectrip_be/comment/service/impl/AccompanyCommentServiceImpl.java +++ b/src/main/java/connectripbe/connectrip_be/comment/service/impl/AccompanyCommentServiceImpl.java @@ -10,6 +10,8 @@ import connectripbe.connectrip_be.global.util.bucket4j.annotation.RateLimit; import connectripbe.connectrip_be.member.entity.MemberEntity; import connectripbe.connectrip_be.member.repository.MemberJpaRepository; +import connectripbe.connectrip_be.notification.dto.NotificationCommentResponse; +import connectripbe.connectrip_be.notification.service.NotificationService; import connectripbe.connectrip_be.post.entity.AccompanyPostEntity; import connectripbe.connectrip_be.post.repository.AccompanyPostRepository; import java.util.List; @@ -24,10 +26,12 @@ public class AccompanyCommentServiceImpl implements AccompanyCommentService { private final AccompanyCommentRepository accompanyCommentRepository; private final MemberJpaRepository memberRepository; private final AccompanyPostRepository accompanyPostRepository; + private final NotificationService notificationService; // 주입된 notificationService + /** - * 댓글을 생성하는 메서드. 사용자 이메일을 통해 MemberEntity 를 조회하고, 게시물 ID를 통해 AccompanyPostEntity 를 조회한 후 AccompanyCommentEntity 를 - * 생성하여 데이터베이스에 저장 + * 댓글을 생성하는 메서드. 사용자 이메일을 통해 MemberEntity를 조회하고, 게시물 ID를 통해 AccompanyPostEntity를 조회한 후 AccompanyCommentEntity를 생성하여 + * 데이터베이스에 저장 * * @param memberId 댓글 작성자의 아이디 * @param request 댓글 생성 요청 정보 (게시물 ID, 댓글 내용 포함) @@ -39,6 +43,7 @@ public AccompanyCommentResponse createComment(Long memberId, AccompanyCommentReq MemberEntity member = getMember(memberId); AccompanyPostEntity post = getPost(request.getPostId()); + // 댓글 생성 AccompanyCommentEntity comment = AccompanyCommentEntity.builder() .memberEntity(member) .accompanyPostEntity(post) @@ -47,9 +52,30 @@ public AccompanyCommentResponse createComment(Long memberId, AccompanyCommentReq accompanyCommentRepository.save(comment); + // 댓글 내용에서 첫 20자를 추출하는 로직 추가 + String limitedContent = limitContentTo20Characters(request.getContent()); + + // NotificationCommentResponse 생성 (댓글 내용은 첫 20자만) + NotificationCommentResponse notificationResponse = NotificationCommentResponse.fromEntity(comment, + limitedContent); + + // 게시글 작성자에게 실시간 알림 전송 + notificationService.sendNotification(post.getMemberEntity().getId(), notificationResponse); + return AccompanyCommentResponse.fromEntity(comment); } + /** + * 댓글 내용을 20자로 제한하는 메서드 + * + * @param content 댓글 내용 + * @return 20자 이하로 잘린 댓글 내용 + */ + private String limitContentTo20Characters(String content) { + return content.length() > 20 ? content.substring(0, 20) : content; + } + + /** * 댓글을 수정하는 메서드. 주어진 댓글 ID를 통해 AccompanyCommentEntity를 조회하고, 수정 권한이 있는지 확인한 후 댓글 내용을 업데이트 * diff --git a/src/main/java/connectripbe/connectrip_be/member/entity/MemberEntity.java b/src/main/java/connectripbe/connectrip_be/member/entity/MemberEntity.java index 8c48fc97..6860ee8f 100644 --- a/src/main/java/connectripbe/connectrip_be/member/entity/MemberEntity.java +++ b/src/main/java/connectripbe/connectrip_be/member/entity/MemberEntity.java @@ -81,4 +81,8 @@ public void profileUpdate(String nickname, String description) { this.description = description; } + public MemberEntity(Long id) { + this.id = id; + } + } diff --git a/src/main/java/connectripbe/connectrip_be/notification/dto/NotificationCommentResponse.java b/src/main/java/connectripbe/connectrip_be/notification/dto/NotificationCommentResponse.java new file mode 100644 index 00000000..eedf5034 --- /dev/null +++ b/src/main/java/connectripbe/connectrip_be/notification/dto/NotificationCommentResponse.java @@ -0,0 +1,32 @@ +package connectripbe.connectrip_be.notification.dto; + +import connectripbe.connectrip_be.comment.entity.AccompanyCommentEntity; +import java.time.LocalDateTime; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class NotificationCommentResponse { + + private Long userId; // 댓글을 남긴 사용자 ID + private String userNickname; // 댓글을 남긴 사용자 닉네임 + private String userProfilePath; // 댓글을 남긴 사용자 프로필 이미지 경로 + private Long postId; // 댓글이 달린 게시물 ID + private String content; // 댓글 내용 (글자 제한 적용) + private LocalDateTime notificationTime; // 알림 생성 시간 + private boolean isRead; // 읽음 여부 + + + public static NotificationCommentResponse fromEntity(AccompanyCommentEntity comment, String content) { + return NotificationCommentResponse.builder() + .userId(comment.getMemberEntity().getId()) + .userNickname(comment.getMemberEntity().getNickname()) + .userProfilePath(comment.getMemberEntity().getProfileImagePath()) + .postId(comment.getAccompanyPostEntity().getId()) + .content(content) + .notificationTime(LocalDateTime.now()) + .isRead(false) + .build(); + } +} diff --git a/src/main/java/connectripbe/connectrip_be/notification/entity/NotificationEntity.java b/src/main/java/connectripbe/connectrip_be/notification/entity/NotificationEntity.java new file mode 100644 index 00000000..fba28988 --- /dev/null +++ b/src/main/java/connectripbe/connectrip_be/notification/entity/NotificationEntity.java @@ -0,0 +1,47 @@ +package connectripbe.connectrip_be.notification.entity; + +import connectripbe.connectrip_be.global.entity.BaseEntity; +import connectripbe.connectrip_be.member.entity.MemberEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "notifications") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class NotificationEntity extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private MemberEntity member; // 알림을 받을 사용자 + + @Column(nullable = false, length = 256) + private String message; // 알림 내용 + + @Column(name = "read_at") + private LocalDateTime readAt; // 읽은 시간 + + public void markAsRead() { + this.readAt = ZonedDateTime.now(ZoneId.of("UTC")).toLocalDateTime(); + } +} diff --git a/src/main/java/connectripbe/connectrip_be/notification/repository/NotificationRepository.java b/src/main/java/connectripbe/connectrip_be/notification/repository/NotificationRepository.java new file mode 100644 index 00000000..c04b0d35 --- /dev/null +++ b/src/main/java/connectripbe/connectrip_be/notification/repository/NotificationRepository.java @@ -0,0 +1,12 @@ +package connectripbe.connectrip_be.notification.repository; + +import connectripbe.connectrip_be.notification.entity.NotificationEntity; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface NotificationRepository extends JpaRepository { + List findByMemberIdAndReadAtIsNull(Long memberId); +} + diff --git a/src/main/java/connectripbe/connectrip_be/notification/service/NotificationService.java b/src/main/java/connectripbe/connectrip_be/notification/service/NotificationService.java new file mode 100644 index 00000000..52c7c26b --- /dev/null +++ b/src/main/java/connectripbe/connectrip_be/notification/service/NotificationService.java @@ -0,0 +1,15 @@ +package connectripbe.connectrip_be.notification.service; + +import connectripbe.connectrip_be.notification.dto.NotificationCommentResponse; +import connectripbe.connectrip_be.notification.entity.NotificationEntity; +import java.util.List; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +public interface NotificationService { + + SseEmitter subscribe(Long memberId); // SSE 연결 구독 + + void sendNotification(Long memberId, NotificationCommentResponse notificationResponse); // 알림 전송 + + List getUnreadNotifications(Long memberId); // 읽지 않은 알림 조회 +} diff --git a/src/main/java/connectripbe/connectrip_be/notification/service/impl/NotificationServiceImpl.java b/src/main/java/connectripbe/connectrip_be/notification/service/impl/NotificationServiceImpl.java new file mode 100644 index 00000000..522d0375 --- /dev/null +++ b/src/main/java/connectripbe/connectrip_be/notification/service/impl/NotificationServiceImpl.java @@ -0,0 +1,89 @@ +package connectripbe.connectrip_be.notification.service.impl; + +import connectripbe.connectrip_be.global.exception.GlobalException; +import connectripbe.connectrip_be.global.exception.type.ErrorCode; +import connectripbe.connectrip_be.member.entity.MemberEntity; +import connectripbe.connectrip_be.member.repository.MemberJpaRepository; +import connectripbe.connectrip_be.notification.dto.NotificationCommentResponse; +import connectripbe.connectrip_be.notification.entity.NotificationEntity; +import connectripbe.connectrip_be.notification.repository.NotificationRepository; +import connectripbe.connectrip_be.notification.service.NotificationService; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Service +@RequiredArgsConstructor +public class NotificationServiceImpl implements NotificationService { + + private final NotificationRepository notificationRepository; + private final MemberJpaRepository memberJpaRepository; + private final Map emitters = new ConcurrentHashMap<>(); + + /** + * 사용자가 알림을 구독할 수 있도록 SSE 연결을 설정하는 메서드. SSE 연결을 통해 실시간 알림을 구독하며, 연결이 완료되면 "알림 연결 완료" 메시지를 전송합니다. + * + * @param memberId 알림을 구독할 사용자의 ID + * @return SSE Emitter 객체 (알림 구독을 위한 연결) + */ + @Override + public SseEmitter subscribe(Long memberId) { + SseEmitter emitter = new SseEmitter(Long.MAX_VALUE); + emitters.put(memberId, emitter); + + try { + emitter.send(SseEmitter.event().name("CONNECTED").data("알림 연결 완료")); + } catch (IOException e) { + emitters.remove(memberId); + } + + emitter.onCompletion(() -> emitters.remove(memberId)); + emitter.onTimeout(() -> emitters.remove(memberId)); + + return emitter; + } + + @Override + public void sendNotification(Long memberId, NotificationCommentResponse notificationResponse) { + + // 사용자 ID로 MemberEntity 조회 + MemberEntity member = memberJpaRepository.findById(memberId) + .orElseThrow(() -> new GlobalException(ErrorCode.USER_NOT_FOUND)); + + // NotificationEntity를 빌더 패턴으로 생성 + NotificationEntity notification = NotificationEntity.builder() + .member(member) + .message(notificationResponse.getContent()) + .build(); + + notificationRepository.save(notification); + + // 알림을 받을 사용자에게 실시간 알림 전송 + SseEmitter emitter = emitters.get(memberId); + if (emitter != null) { + try { + emitter.send(SseEmitter.event() + .name("COMMENT_ADDED") + .data(notificationResponse)); // NotificationCommentResponse 객체 전송 + } catch (IOException e) { + emitters.remove(memberId); + } + } + } + + + /** + * 특정 사용자의 읽지 않은 알림 목록을 조회하는 메서드. 읽지 않은 알림(읽음 시간이 설정되지 않은 알림)을 데이터베이스에서 조회하여 반환합니다. + * + * @param memberId 알림을 조회할 사용자의 ID + * @return 읽지 않은 알림 목록 + */ + @Override + public List getUnreadNotifications(Long memberId) { + return notificationRepository.findByMemberIdAndReadAtIsNull(memberId); + } +} diff --git a/src/main/java/connectripbe/connectrip_be/notification/web/NotificationController.java b/src/main/java/connectripbe/connectrip_be/notification/web/NotificationController.java new file mode 100644 index 00000000..3599cce2 --- /dev/null +++ b/src/main/java/connectripbe/connectrip_be/notification/web/NotificationController.java @@ -0,0 +1,31 @@ +package connectripbe.connectrip_be.notification.web; + +import connectripbe.connectrip_be.notification.entity.NotificationEntity; +import connectripbe.connectrip_be.notification.service.NotificationService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@RestController +@RequestMapping("/api/v1/comment-notifications") +@RequiredArgsConstructor +public class NotificationController { + + private final NotificationService notificationService; + + @GetMapping("/subscribe/{memberId}") + public SseEmitter subscribe(@PathVariable Long memberId) { + return notificationService.subscribe(memberId); + } + + @GetMapping("/unread/{memberId}") + public ResponseEntity> getUnreadNotifications(@PathVariable Long memberId) { + List notifications = notificationService.getUnreadNotifications(memberId); + return ResponseEntity.ok(notifications); + } +}