diff --git a/src/main/java/UMC/news/newsIntelligent/domain/notification/controller/NotificationController.java b/src/main/java/UMC/news/newsIntelligent/domain/notification/controller/NotificationController.java new file mode 100644 index 0000000..52ccde3 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/notification/controller/NotificationController.java @@ -0,0 +1,38 @@ +package UMC.news.newsIntelligent.domain.notification.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import UMC.news.newsIntelligent.domain.notification.dto.NotificationResponse; +import UMC.news.newsIntelligent.domain.notification.service.NotificationService; +import UMC.news.newsIntelligent.global.apiPayload.CustomResponse; +import UMC.news.newsIntelligent.global.apiPayload.code.success.GeneralSuccessCode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/notification") +@RequiredArgsConstructor +@Tag(name = "사용자 알림 관련 API") +public class NotificationController { + + private final NotificationService notificationService; + + @Operation(summary = "알림 목록 조회 API", + description = "

구독한 토픽, 읽은 토픽에 대한 홈화면 알림 목록입니다." + + "

커서 페이징 처리하였습니다.") + @GetMapping + public CustomResponse getNotifications( + @RequestParam(required = false) String cursor, + @RequestParam(defaultValue = "10") int size + ) { + Long memberId = 0L; + NotificationResponse.NotificationCursorDto body = + notificationService.getNotifications(memberId, cursor, size); + + return CustomResponse.onSuccess(GeneralSuccessCode.OK, body); + } +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/notification/dto/NotificationRequest.java b/src/main/java/UMC/news/newsIntelligent/domain/notification/dto/NotificationRequest.java new file mode 100644 index 0000000..89012d3 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/notification/dto/NotificationRequest.java @@ -0,0 +1,5 @@ +package UMC.news.newsIntelligent.domain.notification.dto; + +public class NotificationRequest { + +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/notification/dto/NotificationResponse.java b/src/main/java/UMC/news/newsIntelligent/domain/notification/dto/NotificationResponse.java new file mode 100644 index 0000000..26c64fa --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/notification/dto/NotificationResponse.java @@ -0,0 +1,61 @@ +package UMC.news.newsIntelligent.domain.notification.dto; + +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.stream.Collectors; + +import UMC.news.newsIntelligent.domain.notification.Notification; +import UMC.news.newsIntelligent.domain.notification.NotificationType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class NotificationResponse { + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class NotificationCursorDto { + private List notifications; + private String nextCursor; + private boolean hasNext; + + // dto 변환 메서드 + public static NotificationCursorDto of(List items, String nextCursor, boolean hasNext) { + List dtos = items.stream() + .map(NotificationDto::of).collect(Collectors.toList()); + return NotificationCursorDto.builder() + .notifications(dtos) + .nextCursor(nextCursor) + .hasNext(hasNext) + .build(); + } + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class NotificationDto { + private NotificationType type; + private String content; + private Boolean isChecked; + private String createdAt; + + // '2011-12-03T10:15:30+01:00' 형식의 포매터 + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME; + + // Notification entity -> Dto + public static NotificationDto of(Notification notification) { + + return NotificationDto.builder() + .content(notification.getContent()) + .type(notification.getNotificationType()) + .isChecked(notification.getIsChecked()) + .createdAt(notification.getCreatedAt().format(FORMATTER)) + .build(); + } + } +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/notification/repository/NotificationRepository.java b/src/main/java/UMC/news/newsIntelligent/domain/notification/repository/NotificationRepository.java new file mode 100644 index 0000000..2e753f0 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,48 @@ +package UMC.news.newsIntelligent.domain.notification.repository; + +import java.awt.print.Pageable; +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import UMC.news.newsIntelligent.domain.notification.Notification; + +public interface NotificationRepository extends JpaRepository { + List findByMemberIdOrderByCreatedAtAsc(Long memberId); + + /** + * 초기 조회: 최신순으로 size+1 개 가져온다. + */ + @Query(""" + SELECT n + FROM Notification n + WHERE n.member.id = :memberId + ORDER BY n.createdAt DESC, n.id DESC + """) + List findByMemberIdBeforeCursor( + @Param("memberId") Long memberId, + @Param("limit") int limit); + + /** + * 커서 다음 조회: + * createdAt < cursor.createdAt + * (createdAt = cursor.createdAt AND id < cursor.id) + */ + @Query(""" + SELECT n + FROM Notification n + WHERE n.member.id = :memberId + AND (n.createdAt < :createdAt + OR (n.createdAt = :createdAt AND n.id < :id)) + ORDER BY n.createdAt DESC, n.id DESC + """) + List findByMemberIdAfterCursor( + @Param("memberId") Long memberId, + @Param("createdAt")LocalDateTime createdAt, + @Param("id") Long id, + @Param("limit") int limit + ); +} \ No newline at end of file diff --git a/src/main/java/UMC/news/newsIntelligent/domain/notification/service/NotificationService.java b/src/main/java/UMC/news/newsIntelligent/domain/notification/service/NotificationService.java new file mode 100644 index 0000000..2a653d4 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/notification/service/NotificationService.java @@ -0,0 +1,7 @@ +package UMC.news.newsIntelligent.domain.notification.service; + +import UMC.news.newsIntelligent.domain.notification.dto.NotificationResponse; + +public interface NotificationService { + NotificationResponse.NotificationCursorDto getNotifications(Long memberId, String cursor, int size); +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/notification/service/NotificationServiceImpl.java b/src/main/java/UMC/news/newsIntelligent/domain/notification/service/NotificationServiceImpl.java new file mode 100644 index 0000000..d597c55 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/notification/service/NotificationServiceImpl.java @@ -0,0 +1,62 @@ +package UMC.news.newsIntelligent.domain.notification.service; + +import java.time.DateTimeException; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import UMC.news.newsIntelligent.domain.notification.Notification; +import UMC.news.newsIntelligent.domain.notification.dto.NotificationResponse; +import UMC.news.newsIntelligent.domain.notification.repository.NotificationRepository; +import UMC.news.newsIntelligent.global.apiPayload.code.error.GeneralErrorCode; +import UMC.news.newsIntelligent.global.apiPayload.exception.CustomException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class NotificationServiceImpl implements NotificationService { + + private final NotificationRepository notificationRepository; + + @Override + public NotificationResponse.NotificationCursorDto getNotifications( + Long memberId, String cursor, int size + ) { + // 커서 디코딩 + LocalDateTime createdAt = null; + Long id = null; + if(cursor != null && !cursor.isBlank()) { + try { + String decoded = new String(Base64.getUrlDecoder().decode(cursor)); + String[] parts = decoded.split("_", 2); + createdAt = LocalDateTime.parse(parts[0]); + id = Long.parseLong(parts[1]); + + } catch (IllegalArgumentException | DateTimeException e) { + throw new CustomException(GeneralErrorCode.CURSOR_INVALID); + } + } + // size+1 만큼 조회 + int limit = size +1; + List items = (createdAt == null) + ? notificationRepository.findByMemberIdBeforeCursor(memberId, limit) + : notificationRepository.findByMemberIdAfterCursor(memberId, createdAt, id, limit); + + boolean hasNext = items.size() > size; + String nextCursor = null; + if (hasNext) { + Notification pivot = items.get(size); + String raw = pivot.getCreatedAt().toString() + "_" + pivot.getId(); + nextCursor = Base64.getUrlEncoder().encodeToString(raw.getBytes()); + } + List page = hasNext + ? items.subList(0, size) + : items; + + return NotificationResponse.NotificationCursorDto.of(page, nextCursor, hasNext); + } +} diff --git a/src/main/java/UMC/news/newsIntelligent/global/apiPayload/code/error/GeneralErrorCode.java b/src/main/java/UMC/news/newsIntelligent/global/apiPayload/code/error/GeneralErrorCode.java index 2b9600a..68aa888 100644 --- a/src/main/java/UMC/news/newsIntelligent/global/apiPayload/code/error/GeneralErrorCode.java +++ b/src/main/java/UMC/news/newsIntelligent/global/apiPayload/code/error/GeneralErrorCode.java @@ -19,7 +19,9 @@ public enum GeneralErrorCode implements BaseErrorCode{ INTERNAL_SERVER_ERROR_500(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 내부 오류가 발생했습니다"), // 유효성 검사 - VALIDATION_FAILED(HttpStatus.BAD_REQUEST, "VALID400_0", "잘못된 파라미터 입니다.") + VALIDATION_FAILED(HttpStatus.BAD_REQUEST, "VALID400_0", "잘못된 파라미터 입니다."), + // 커서 에러 + CURSOR_INVALID(HttpStatus.BAD_REQUEST, "CURSOR400", "커서가 유효하지 않습니다.") ; // 필요한 필드값 선언