diff --git a/build.gradle b/build.gradle index 1002a12e..11dc1b99 100644 --- a/build.gradle +++ b/build.gradle @@ -50,6 +50,10 @@ dependencies { // Health Check implementation 'org.springframework.boot:spring-boot-starter-actuator' + // Spring Retry 및 AOP (알림 저장 실패 재시도용) + implementation 'org.springframework.retry:spring-retry' + implementation 'org.springframework.boot:spring-boot-starter-aop' + testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/src/main/java/com/mr/Application.java b/src/main/java/com/mr/Application.java index 75e73636..d6846c64 100644 --- a/src/main/java/com/mr/Application.java +++ b/src/main/java/com/mr/Application.java @@ -3,7 +3,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.retry.annotation.EnableRetry; +@EnableRetry @EnableJpaAuditing @SpringBootApplication public class Application { diff --git a/src/main/java/com/mr/domain/notification/controller/NotificationController.java b/src/main/java/com/mr/domain/notification/controller/NotificationController.java new file mode 100644 index 00000000..14408bb8 --- /dev/null +++ b/src/main/java/com/mr/domain/notification/controller/NotificationController.java @@ -0,0 +1,63 @@ +package com.mr.domain.notification.controller; + +import com.mr.domain.notification.dto.res.NotificationListResponseDTO; +import com.mr.domain.notification.service.NotificationService; +import com.mr.global.apipayload.ApiResponse; +import com.mr.global.security.principal.CustomUserDetails; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/notifications") +@RequiredArgsConstructor +public class NotificationController { + + private final NotificationService notificationService; + + @GetMapping + public ApiResponse getNotifications( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PageableDefault(page = 0, size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable + ){ + Long userId = userDetails.getUserId(); + NotificationListResponseDTO result = notificationService.getNotificationList(userId, pageable); + return ApiResponse.onSuccess(result); + } + + // 알림 읽음 처리 + @PatchMapping("/{notificationId}/read") + public ApiResponse readNotification( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Long notificationId + ) { + Long userId = userDetails.getUserId(); + notificationService.readNotification(userId, notificationId); + return ApiResponse.onSuccess(null); + } + + // 알림 전체 읽음 처리 + @PatchMapping("/read-all") + public ApiResponse readAllNotifications( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + notificationService.readAllNotifications(userDetails.getUserId()); + return ApiResponse.onSuccess(null); + } + + // 안 읽은 알림 여부 확인 (사이드바 뱃지용) + @GetMapping("/unread-status") + public ApiResponse checkUnreadStatus( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + boolean hasUnread = notificationService.checkUnreadNotification(userDetails.getUserId()); + return ApiResponse.onSuccess(hasUnread); + } +} diff --git a/src/main/java/com/mr/domain/notification/dto/res/NotificationListResponseDTO.java b/src/main/java/com/mr/domain/notification/dto/res/NotificationListResponseDTO.java new file mode 100644 index 00000000..d92939e4 --- /dev/null +++ b/src/main/java/com/mr/domain/notification/dto/res/NotificationListResponseDTO.java @@ -0,0 +1,38 @@ +package com.mr.domain.notification.dto.res; + +import com.mr.domain.notification.entity.Notification; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Slice; + +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NotificationListResponseDTO { + private List notificationList; + private Integer listSize; + private Boolean hasNext; + private Boolean isFirst; + private Boolean isLast; + + public static NotificationListResponseDTO of(Slice notificationSlice) { + List notificationDTOList = notificationSlice.getContent().stream() + .map(NotificationResponseDTO::from) + .collect(Collectors.toList()); + + return NotificationListResponseDTO.builder() + .notificationList(notificationDTOList) + .listSize(notificationDTOList.size()) + .hasNext(notificationSlice.hasNext()) + .isFirst(notificationSlice.isFirst()) + .isLast(notificationSlice.isLast()) + .build(); + } +} diff --git a/src/main/java/com/mr/domain/notification/dto/res/NotificationResponseDTO.java b/src/main/java/com/mr/domain/notification/dto/res/NotificationResponseDTO.java new file mode 100644 index 00000000..a3f0c3cf --- /dev/null +++ b/src/main/java/com/mr/domain/notification/dto/res/NotificationResponseDTO.java @@ -0,0 +1,32 @@ +package com.mr.domain.notification.dto.res; + +import com.mr.domain.notification.entity.Notification; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NotificationResponseDTO { + + private Long notificationId; + private String title; + private String content; + private Boolean isRead; + private LocalDateTime createdAt; + + public static NotificationResponseDTO from(Notification notification) { + return NotificationResponseDTO.builder() + .notificationId(notification.getId()) + .title(notification.getTitle()) + .content(notification.getContent()) + .isRead(notification.isRead()) + .createdAt(notification.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/mr/domain/notification/entity/Notification.java b/src/main/java/com/mr/domain/notification/entity/Notification.java index 585822fb..4db4994a 100644 --- a/src/main/java/com/mr/domain/notification/entity/Notification.java +++ b/src/main/java/com/mr/domain/notification/entity/Notification.java @@ -1,12 +1,16 @@ package com.mr.domain.notification.entity; +import com.mr.domain.user.entity.User; import com.mr.global.entity.BaseTimeDeletedEntity; 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.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Builder; @@ -36,8 +40,9 @@ public class Notification extends BaseTimeDeletedEntity{ private Long id; // 유저 아이디 - @Column(name = "user_id", nullable = false) - private Long userId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; // 제목 @Column(name = "title", nullable = false, length = 100) @@ -53,16 +58,20 @@ public class Notification extends BaseTimeDeletedEntity{ @Builder(access = AccessLevel.PRIVATE) - private Notification(Long userId, String title, String content) { - this.userId = userId; + private Notification(User user, String title, String content) { + validateUser(user); + validateTitle(title); + validateContent(content); + + this.user = user; this.title = title; this.content = content; this.isRead = false; } - public static Notification create(Long userId, String title, String content) { + public static Notification create(User user, String title, String content) { return Notification.builder() - .userId(userId) + .user(user) .title(title) .content(content) .build(); @@ -72,4 +81,22 @@ public static Notification create(Long userId, String title, String content) { public void markAsRead() { this.isRead = true; } + + private void validateUser(User user) { + if (user == null) { + throw new IllegalArgumentException("User는 필수입니다."); + } + } + + private void validateTitle(String title) { + if (title == null || title.trim().isEmpty()) { + throw new IllegalArgumentException("Title은 필수입니다."); + } + } + + private void validateContent(String content) { + if (content == null || content.trim().isEmpty()) { + throw new IllegalArgumentException("Content는 필수입니다."); + } + } } \ No newline at end of file diff --git a/src/main/java/com/mr/domain/notification/exception/NotificationErrorStatus.java b/src/main/java/com/mr/domain/notification/exception/NotificationErrorStatus.java new file mode 100644 index 00000000..bd94b030 --- /dev/null +++ b/src/main/java/com/mr/domain/notification/exception/NotificationErrorStatus.java @@ -0,0 +1,21 @@ +package com.mr.domain.notification.exception; + +import com.mr.global.apipayload.code.BaseCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum NotificationErrorStatus implements BaseCode { + + // [403] 권한 에러 + FORBIDDEN_NOTIFICATION(HttpStatus.FORBIDDEN, "NOTIFICATION_403_01", "해당 알림에 대한 권한이 없습니다."), + + // [404] 리소스 없음 + NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "NOTIFICATION_404_01", "존재하지 않는 알림입니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/mr/domain/notification/repository/NotificationRepository.java b/src/main/java/com/mr/domain/notification/repository/NotificationRepository.java new file mode 100644 index 00000000..e54508c4 --- /dev/null +++ b/src/main/java/com/mr/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,29 @@ +package com.mr.domain.notification.repository; + +import com.mr.domain.notification.entity.Notification; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDateTime; +import java.util.Optional; + +public interface NotificationRepository extends JpaRepository { + + Page findAllByUser_UserIdAndDeletedAtIsNull(Long userId, Pageable pageable); + + Optional findByIdAndDeletedAtIsNull(Long id); + + @Modifying(clearAutomatically = true) + @Query("update Notification n set n.isRead = true where n.user.userId = :userId and n.isRead = false and n.deletedAt is null") + void bulkMarkAllAsReadByUserId(@Param("userId") Long userId); + + // 유저에게 안 읽은 알림이 단 하나라도 존재하는지 확인 (사이드바 종 아이콘 뱃지용) + boolean existsByUser_UserIdAndIsReadFalseAndDeletedAtIsNull(Long userId); + + // 중복 알림 방지용 쿼리 메서드 + boolean existsByUser_UserIdAndTitleAndCreatedAtAfter(Long userId, String title, LocalDateTime time); +} diff --git a/src/main/java/com/mr/domain/notification/service/NotificationEventListener.java b/src/main/java/com/mr/domain/notification/service/NotificationEventListener.java new file mode 100644 index 00000000..0d0fee05 --- /dev/null +++ b/src/main/java/com/mr/domain/notification/service/NotificationEventListener.java @@ -0,0 +1,69 @@ +package com.mr.domain.notification.service; + +import com.mr.domain.notification.entity.Notification; +import com.mr.domain.notification.repository.NotificationRepository; +import com.mr.domain.user.entity.User; +import com.mr.domain.user.exception.UserErrorStatus; +import com.mr.domain.user.repository.UserRepository; +import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.event.NotificationEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.time.LocalDateTime; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NotificationEventListener { + + private final NotificationRepository notificationRepository; + private final UserRepository userRepository; + + //누군가 NotificationEvent를 발행하면 이 메서드가 자동으로 실행 + // DB 저장 실패 시 최대 3번 재시도 (1초 간격) + @Retryable( + retryFor = {Exception.class}, + maxAttempts = 3, + backoff = @Backoff(delay = 1000) + ) + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) // 메인 트랜잭션이 커밋된(AFTER_COMMIT) 이후에만 알림을 저장하도록 변경 + @Transactional + public void handleNotificationEvent(NotificationEvent event) { + + // 중복 알림 검증 로직 (1분 이내 동일 제목 알림 무시) + LocalDateTime oneMinuteAgo = LocalDateTime.now().minusMinutes(1); + if (notificationRepository.existsByUser_UserIdAndTitleAndCreatedAtAfter( + event.getUserId(), event.getTitle(), oneMinuteAgo)) { + return; // 걸리면 바로 종료 + } + + User user = userRepository.findById(event.getUserId()) + .orElseThrow(() -> new GeneralException(UserErrorStatus.USER_NOT_FOUND)); + + Notification notification = Notification.create( + user, + event.getTitle(), + event.getContent() + ); + + notificationRepository.save(notification); + } + + // 3번의 재시도 전부 실패했을 때 실행되는 '영구 보정 경로(Fallback)' + @Recover + public void recoverNotificationEvent(Exception e, NotificationEvent event) { + // 나중에 이 로그를 수집해서 슬랙 알림을 보내거나, 수동으로 복구할 수 있도록 단서를 남김 + log.error("[알림 저장 최종 실패] userId: {}, title: {}, 원인: {}", + event.getUserId(), event.getTitle(), e.getMessage()); + } +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/notification/service/NotificationService.java b/src/main/java/com/mr/domain/notification/service/NotificationService.java new file mode 100644 index 00000000..e8b00176 --- /dev/null +++ b/src/main/java/com/mr/domain/notification/service/NotificationService.java @@ -0,0 +1,50 @@ +package com.mr.domain.notification.service; + +import com.mr.domain.notification.dto.res.NotificationListResponseDTO; +import com.mr.domain.notification.entity.Notification; +import com.mr.domain.notification.exception.NotificationErrorStatus; +import com.mr.domain.notification.repository.NotificationRepository; +import com.mr.global.apipayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.data.domain.Pageable; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NotificationService { + + private final NotificationRepository notificationRepository; + + public NotificationListResponseDTO getNotificationList(Long userId, Pageable pageable){ + Page notificationPage = notificationRepository.findAllByUser_UserIdAndDeletedAtIsNull(userId, pageable); + return NotificationListResponseDTO.of(notificationPage); + } + + // 알림 읽음 처리 + @Transactional + public void readNotification(Long userId, Long notificationId) { + Notification notification = notificationRepository.findByIdAndDeletedAtIsNull(notificationId) + .orElseThrow(() -> new GeneralException(NotificationErrorStatus.NOTIFICATION_NOT_FOUND)); + + // 본인 알림이 맞는지 검증 (선택 사항이나 보안상 권장) + if (!notification.getUser().getUserId().equals(userId)) { + throw new GeneralException(NotificationErrorStatus.FORBIDDEN_NOTIFICATION); + } + + notification.markAsRead(); + } + + // 알림 전체 읽음 처리 + @Transactional + public void readAllNotifications(Long userId) { + notificationRepository.bulkMarkAllAsReadByUserId(userId); + } + + // 안 읽은 알림 존재 여부 확인 + public boolean checkUnreadNotification(Long userId) { + return notificationRepository.existsByUser_UserIdAndIsReadFalseAndDeletedAtIsNull(userId); + } +} diff --git a/src/main/java/com/mr/global/event/NotificationEvent.java b/src/main/java/com/mr/global/event/NotificationEvent.java new file mode 100644 index 00000000..cc376151 --- /dev/null +++ b/src/main/java/com/mr/global/event/NotificationEvent.java @@ -0,0 +1,35 @@ +package com.mr.global.event; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class NotificationEvent { + private Long userId; + private String title; + private String content; + + // 연주 분석 알림 팩토리 (곡 이름만 받아서 제목 생성) + public static NotificationEvent forAnalysis(Long userId, String practiceName) { + // 곡 이름이 너무 길면 잘라내기 (말줄임표 처리) + if (practiceName.length() > 30) { + practiceName = practiceName.substring(0, 30) + "..."; + } + String generatedTitle = practiceName + " 연주 분석이 완료되었습니다."; + return new NotificationEvent(userId, generatedTitle, "지금 바로 분석 결과를 확인해보세요!"); + } + + // 연습 시간 달성 알림 팩토리 (유저 이름과 시간만 받아서 제목 생성) + public static NotificationEvent forPractice(Long userId, String userName, int hours) { + String generatedTitle = userName + " 님, 이번 주 연습 " + hours + "시간을 달성했습니다!"; + return new NotificationEvent(userId, generatedTitle, "꾸준한 연습이 멋진 연주를 만듭니다!"); + } + + // 학습 완료 알림 팩토리 (학습 주제만 받아서 제목 생성) + public static NotificationEvent forLearning(Long userId, String topicName) { + String generatedTitle = topicName + " 학습을 모두 완료했습니다."; + return new NotificationEvent(userId, generatedTitle, "다음 학습으로 넘어가 볼까요?"); + } +} \ No newline at end of file