diff --git a/src/main/java/UMC/news/newsIntelligent/domain/member/controller/MemberTopicController.java b/src/main/java/UMC/news/newsIntelligent/domain/member/controller/MemberTopicController.java new file mode 100644 index 0000000..d09785a --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/member/controller/MemberTopicController.java @@ -0,0 +1,83 @@ +package UMC.news.newsIntelligent.domain.member.controller; + +import UMC.news.newsIntelligent.domain.member.dto.MemberTopicResponseDTO; +import UMC.news.newsIntelligent.domain.member.service.MemberTopicQueryService; +import UMC.news.newsIntelligent.global.apiPayload.CustomResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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 java.security.Principal; + +@RestController +@RequestMapping("/api/mypage") +@RequiredArgsConstructor +@Tag(name = "마이페이지 내에서의 토픽 목록 조회 컨트롤러", description = "마이페이지 내에서의 조회와 관련된 API들을 관리하는 컨트롤러") +public class MemberTopicController { + + private final MemberTopicQueryService memberTopicQueryService; + + @Operation(summary = "읽은 토픽 목록 내 조회 API by 서동혁", description = "읽은 토픽 목록 내 조회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "토픽 조회 성공") + }) + @GetMapping("/read-topics") + public CustomResponse searchReadTopics( + @RequestParam String keyword, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "10") @Max(10) int size + // @AuthenticationPrincipal PrincipalDetails principalDetails + ) { + + // 로그인 기능 구현 이후 memberId(1) 대신 매개변수로 principalDetails + MemberTopicResponseDTO.MemberTopicPreviewListResDTO topicResDTO = + memberTopicQueryService.searchReadTopics(keyword, cursor, size, 1L); + + return CustomResponse.onSuccess(topicResDTO); + } + + @Operation(summary = "읽은 토픽 목록 리스트 조회 API by 서동혁", description = "읽은 토픽 목록 리스트 조회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "토픽 리스트 조회 성공") + }) + @GetMapping("/read-topics") + public CustomResponse getReadTopics( + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "10") @Max(10) int size + // @AuthenticationPrincipal PrincipalDetails principalDetails + ) { + + // 로그인 기능 구현 이후 memberId(1) 대신 매개변수로 principalDetails + MemberTopicResponseDTO.MemberTopicPreviewListResDTO topicResDTO = + memberTopicQueryService.getReadTopics(cursor, size, 1L); + + return CustomResponse.onSuccess(topicResDTO); + } + + @Operation(summary = "구독 토픽 리스트 조회 API by 서동혁", description = "구독 토픽 리스트 조회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "구독 토픽 리스트 조회 성공") + }) + @GetMapping("/subscriptions") + public CustomResponse getSubscriptionTopics( + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "10") @Max(10) int size + // @AuthenticationPrincipal PrincipalDetails principalDetails + ) { + + // 로그인 기능 구현 이후 memberId(1) 대신 매개변수로 principalDetails + MemberTopicResponseDTO.MemberTopicPreviewListResDTO topicResDTO = + memberTopicQueryService.getSubscriptionTopics(cursor, size, 1L); + + return CustomResponse.onSuccess(topicResDTO); + } + +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/member/converter/MemberTopicConverter.java b/src/main/java/UMC/news/newsIntelligent/domain/member/converter/MemberTopicConverter.java new file mode 100644 index 0000000..7a185e9 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/member/converter/MemberTopicConverter.java @@ -0,0 +1,19 @@ +package UMC.news.newsIntelligent.domain.member.converter; + +import UMC.news.newsIntelligent.domain.member.dto.MemberTopicResponseDTO; +import UMC.news.newsIntelligent.domain.topic.entity.Topic; + +public class MemberTopicConverter { + + public static MemberTopicResponseDTO.MemberTopicPreviewResDTO toPreviewResDTO(Topic topic) { + + return MemberTopicResponseDTO.MemberTopicPreviewResDTO.builder() + .id(topic.getId()) + .topicName(topic.getTopicName()) + .aiSummary(topic.getAiSummary()) + .imageUrl(topic.getImageUrl()) + .summaryTime(topic.getSummaryTime()) + .build(); + } + +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/member/dto/MemberTopicResponseDTO.java b/src/main/java/UMC/news/newsIntelligent/domain/member/dto/MemberTopicResponseDTO.java new file mode 100644 index 0000000..7f5270e --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/member/dto/MemberTopicResponseDTO.java @@ -0,0 +1,27 @@ +package UMC.news.newsIntelligent.domain.member.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +public class MemberTopicResponseDTO { + + @Builder + public record MemberTopicPreviewResDTO( + Long id, + String topicName, + String aiSummary, + LocalDateTime summaryTime, + String imageUrl + ) {} + + @Builder + public record MemberTopicPreviewListResDTO( + Long cursor, + Boolean hasNext, + List topics + ) {} +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/member/entity/Member.java b/src/main/java/UMC/news/newsIntelligent/domain/member/entity/Member.java index 982638b..752cb3c 100644 --- a/src/main/java/UMC/news/newsIntelligent/domain/member/entity/Member.java +++ b/src/main/java/UMC/news/newsIntelligent/domain/member/entity/Member.java @@ -1,6 +1,7 @@ package UMC.news.newsIntelligent.domain.member.entity; import java.time.LocalDateTime; + import java.util.ArrayList; import java.util.List; @@ -14,6 +15,7 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.OneToMany; + import lombok.*; @Entity @@ -61,7 +63,6 @@ public class Member extends BaseEntity { orphanRemoval = true) private List memberTopics = new ArrayList<>(); - public static Member newMember(String email) { return Member.builder() .email(email) diff --git a/src/main/java/UMC/news/newsIntelligent/domain/member/entity/MemberTopic.java b/src/main/java/UMC/news/newsIntelligent/domain/member/entity/MemberTopic.java index 3f1011c..d8ccf61 100644 --- a/src/main/java/UMC/news/newsIntelligent/domain/member/entity/MemberTopic.java +++ b/src/main/java/UMC/news/newsIntelligent/domain/member/entity/MemberTopic.java @@ -1,6 +1,6 @@ package UMC.news.newsIntelligent.domain.member.entity; -import UMC.news.newsIntelligent.domain.topic.Topic; +import UMC.news.newsIntelligent.domain.topic.entity.Topic; import UMC.news.newsIntelligent.global.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -12,8 +12,11 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import lombok.Getter; + @Entity @Table(name = "member_topic") +@Getter public class MemberTopic extends BaseEntity { @Id diff --git a/src/main/java/UMC/news/newsIntelligent/domain/member/repository/MemberTopicRepository.java b/src/main/java/UMC/news/newsIntelligent/domain/member/repository/MemberTopicRepository.java new file mode 100644 index 0000000..049a3dd --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/member/repository/MemberTopicRepository.java @@ -0,0 +1,47 @@ +package UMC.news.newsIntelligent.domain.member.repository; + +import UMC.news.newsIntelligent.domain.member.entity.MemberTopic; +import UMC.news.newsIntelligent.domain.topic.entity.Topic; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface MemberTopicRepository extends JpaRepository { + + // memberTopic 에서 isRead = true 인 것 중, topicName 을 기준으로 keyword 검색 & 커서 페이지네이션 구현 + @Query(""" + SELECT mt.topic FROM MemberTopic mt + WHERE mt.member.id = :memberId + AND mt.isRead = true + AND (:keyword IS NULL OR mt.topic.topicName LIKE %:keyword%) + AND mt.topic.id < :cursor + ORDER BY mt.topic.id DESC +""") + Slice searchReadTopicsByKeyword( + @Param("memberId") Long memberId, + @Param("keyword") String keyword, + @Param("cursor") Long cursor, + Pageable pageable + ); + + @Query(""" + SELECT mt.topic FROM MemberTopic mt + WHERE mt.member.id = :memberId + AND mt.isRead = true + AND mt.topic.id < :cursor + ORDER BY mt.topic.id DESC +""") + Slice getReadTopicsByMemberId(@Param("memberId") Long memberId, @Param("cursor") Long cursor, Pageable pageable); + + @Query(""" + SELECT mt.topic FROM MemberTopic mt + WHERE mt.member.id = :memberId + AND mt.isSubscribe = true + AND mt.topic.id < :cursor + ORDER BY mt.topic.id DESC +""") + Slice getSubscriptionTopicsByMemberId(@Param("memberId") Long memberId, @Param("cursor") Long cursor, Pageable pageable); + +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/member/service/MemberTopicQueryService.java b/src/main/java/UMC/news/newsIntelligent/domain/member/service/MemberTopicQueryService.java new file mode 100644 index 0000000..5783f03 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/member/service/MemberTopicQueryService.java @@ -0,0 +1,10 @@ +package UMC.news.newsIntelligent.domain.member.service; + +import UMC.news.newsIntelligent.domain.member.dto.MemberTopicResponseDTO; + +public interface MemberTopicQueryService { + + MemberTopicResponseDTO.MemberTopicPreviewListResDTO searchReadTopics(String keyword, Long cursor, int size, Long memberId); + MemberTopicResponseDTO.MemberTopicPreviewListResDTO getReadTopics(Long cursor, int size, Long memberId); + MemberTopicResponseDTO.MemberTopicPreviewListResDTO getSubscriptionTopics(Long cursor, int size, Long memberId); +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/member/service/MemberTopicQueryServiceImpl.java b/src/main/java/UMC/news/newsIntelligent/domain/member/service/MemberTopicQueryServiceImpl.java new file mode 100644 index 0000000..d9c7006 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/member/service/MemberTopicQueryServiceImpl.java @@ -0,0 +1,95 @@ +package UMC.news.newsIntelligent.domain.member.service; + +import UMC.news.newsIntelligent.domain.member.converter.MemberTopicConverter; +import UMC.news.newsIntelligent.domain.member.dto.MemberTopicResponseDTO; +import UMC.news.newsIntelligent.domain.member.repository.MemberTopicRepository; +import UMC.news.newsIntelligent.domain.topic.entity.Topic; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberTopicQueryServiceImpl implements MemberTopicQueryService { + + private final MemberTopicRepository memberTopicRepository; + + @Override + public MemberTopicResponseDTO.MemberTopicPreviewListResDTO searchReadTopics(String keyword, Long cursor, int size, Long memberId) { + cursor = normalizeCursor(cursor); + size = normalizeSize(size); + + Pageable pageable = PageRequest.of(0, size); + Slice topicSlice = memberTopicRepository.searchReadTopicsByKeyword(memberId, keyword, cursor, pageable); + + List topicList = topicSlice.stream() + .map(MemberTopicConverter::toPreviewResDTO) + .toList(); + + Long nextCursor = topicSlice.hasNext() ? topicList.get(topicList.size() - 1).id() : null; + + return MemberTopicResponseDTO.MemberTopicPreviewListResDTO.builder() + .cursor(nextCursor) + .hasNext(topicSlice.hasNext()) + .topics(topicList) + .build(); + } + + @Override + public MemberTopicResponseDTO.MemberTopicPreviewListResDTO getReadTopics(Long cursor, int size, Long memberId) { + cursor = normalizeCursor(cursor); + size = normalizeSize(size); + + Pageable pageable = PageRequest.of(0, size); + Slice topicSlice = memberTopicRepository.getReadTopicsByMemberId(memberId, cursor, pageable); + + List topicList = topicSlice.stream() + .map(MemberTopicConverter::toPreviewResDTO) + .toList(); + + Long nextCursor = topicSlice.hasNext() ? topicList.get(topicList.size() - 1).id() : null; + + return MemberTopicResponseDTO.MemberTopicPreviewListResDTO.builder() + .cursor(nextCursor) + .hasNext(topicSlice.hasNext()) + .topics(topicList) + .build(); + } + + @Override + public MemberTopicResponseDTO.MemberTopicPreviewListResDTO getSubscriptionTopics(Long cursor, int size, Long memberId) { + cursor = normalizeCursor(cursor); + size = normalizeSize(size); + + Pageable pageable = PageRequest.of(0, size); + Slice topicSlice = memberTopicRepository.getSubscriptionTopicsByMemberId(memberId, cursor, pageable); + + List topicList = topicSlice.stream() + .map(MemberTopicConverter::toPreviewResDTO) + .toList(); + + Long nextCursor = topicSlice.hasNext() ? topicList.get(topicList.size() - 1).id() : null; + + return MemberTopicResponseDTO.MemberTopicPreviewListResDTO.builder() + .cursor(nextCursor) + .hasNext(topicSlice.hasNext()) + .topics(topicList) + .build(); + } + + private Long normalizeCursor(Long cursor) { + return (cursor == 0) ? Long.MAX_VALUE : cursor; + } + + // 요청 사이즈가 1보다 작으면 기본값 10, 10보다 크면 최대값 10으로 제한 + private int normalizeSize(int size) { + return (size < 1 || size > 10) ? 10 : size; + } + +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/news/News.java b/src/main/java/UMC/news/newsIntelligent/domain/news/News.java index 87b1d6d..43e33e5 100644 --- a/src/main/java/UMC/news/newsIntelligent/domain/news/News.java +++ b/src/main/java/UMC/news/newsIntelligent/domain/news/News.java @@ -1,6 +1,6 @@ package UMC.news.newsIntelligent.domain.news; -import UMC.news.newsIntelligent.domain.topic.Topic; +import UMC.news.newsIntelligent.domain.topic.entity.Topic; import UMC.news.newsIntelligent.global.entity.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/src/main/java/UMC/news/newsIntelligent/domain/topic/controller/TopicController.java b/src/main/java/UMC/news/newsIntelligent/domain/topic/controller/TopicController.java new file mode 100644 index 0000000..7b0d0d5 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/topic/controller/TopicController.java @@ -0,0 +1,40 @@ +package UMC.news.newsIntelligent.domain.topic.controller; + +import UMC.news.newsIntelligent.domain.topic.dto.TopicResponseDTO; +import UMC.news.newsIntelligent.domain.topic.service.query.TopicQueryService; +import UMC.news.newsIntelligent.global.apiPayload.CustomResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; +import lombok.RequiredArgsConstructor; +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; + +@RestController +@RequestMapping("/api/topics") +@RequiredArgsConstructor +@Tag(name = "토픽 컨트롤러", description = "토픽과 관련된 API들을 관리하는 컨트롤러") +public class TopicController { + + private final TopicQueryService topicQueryService; + + @Operation(summary = "토픽 조회 API by 서동혁", description = "토픽 조회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "토픽 조회 성공") + }) + @GetMapping("/search") + public CustomResponse searchTopics( + @RequestParam String keyword, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "10") @Max(10) int size + ) { + TopicResponseDTO.TopicPreviewListResDTO topicResDTO = topicQueryService.searchTopics(keyword, cursor, size); + + return CustomResponse.onSuccess(topicResDTO); + } + +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/topic/converter/TopicConverter.java b/src/main/java/UMC/news/newsIntelligent/domain/topic/converter/TopicConverter.java new file mode 100644 index 0000000..2401281 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/topic/converter/TopicConverter.java @@ -0,0 +1,17 @@ +package UMC.news.newsIntelligent.domain.topic.converter; + +import UMC.news.newsIntelligent.domain.topic.entity.Topic; +import UMC.news.newsIntelligent.domain.topic.dto.TopicResponseDTO; + +public class TopicConverter { + + public static TopicResponseDTO.TopicPreviewResDTO toPreviewResDTO(Topic topic) { + return TopicResponseDTO.TopicPreviewResDTO.builder() + .id(topic.getId()) + .topicName(topic.getTopicName()) + .aiSummary(topic.getAiSummary()) + .imageUrl(topic.getImageUrl()) + .summaryTime(topic.getSummaryTime()) + .build(); + } +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/topic/dto/TopicResponseDTO.java b/src/main/java/UMC/news/newsIntelligent/domain/topic/dto/TopicResponseDTO.java new file mode 100644 index 0000000..5edbb2b --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/topic/dto/TopicResponseDTO.java @@ -0,0 +1,27 @@ +package UMC.news.newsIntelligent.domain.topic.dto; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.List; + +@Getter +public class TopicResponseDTO { + + @Builder + public record TopicPreviewResDTO( + Long id, + String topicName, + String aiSummary, + LocalDateTime summaryTime, + String imageUrl + ) {} + + @Builder + public record TopicPreviewListResDTO( + Long cursor, + Boolean hasNext, + List topics + ) {} +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/topic/Topic.java b/src/main/java/UMC/news/newsIntelligent/domain/topic/entity/Topic.java similarity index 94% rename from src/main/java/UMC/news/newsIntelligent/domain/topic/Topic.java rename to src/main/java/UMC/news/newsIntelligent/domain/topic/entity/Topic.java index 78e1e72..e66c86f 100644 --- a/src/main/java/UMC/news/newsIntelligent/domain/topic/Topic.java +++ b/src/main/java/UMC/news/newsIntelligent/domain/topic/entity/Topic.java @@ -1,4 +1,4 @@ -package UMC.news.newsIntelligent.domain.topic; +package UMC.news.newsIntelligent.domain.topic.entity; import java.time.LocalDateTime; diff --git a/src/main/java/UMC/news/newsIntelligent/domain/topic/repository/TopicRepository.java b/src/main/java/UMC/news/newsIntelligent/domain/topic/repository/TopicRepository.java new file mode 100644 index 0000000..4d481a6 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/topic/repository/TopicRepository.java @@ -0,0 +1,20 @@ +package UMC.news.newsIntelligent.domain.topic.repository; + +import UMC.news.newsIntelligent.domain.topic.entity.Topic; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface TopicRepository extends JpaRepository { + + // topicName 을 기준으로 keyword 검색 & 커서 페이지네이션 구현 + @Query(""" + SELECT t FROM Topic t + WHERE (:keyword IS NULL OR t.topicName LIKE %:keyword%) + AND t.id < :cursor + ORDER BY t.id DESC + """) + Slice findByKeywordAndCursor(@Param("keyword") String keyword, @Param("cursor") Long cursor, Pageable pageable); +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/topic/service/query/TopicQueryService.java b/src/main/java/UMC/news/newsIntelligent/domain/topic/service/query/TopicQueryService.java new file mode 100644 index 0000000..13a4c45 --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/topic/service/query/TopicQueryService.java @@ -0,0 +1,7 @@ +package UMC.news.newsIntelligent.domain.topic.service.query; + +import UMC.news.newsIntelligent.domain.topic.dto.TopicResponseDTO; + +public interface TopicQueryService { + TopicResponseDTO.TopicPreviewListResDTO searchTopics(String keyword, Long cursor, int size); +} diff --git a/src/main/java/UMC/news/newsIntelligent/domain/topic/service/query/TopicQueryServiceImpl.java b/src/main/java/UMC/news/newsIntelligent/domain/topic/service/query/TopicQueryServiceImpl.java new file mode 100644 index 0000000..132d8bf --- /dev/null +++ b/src/main/java/UMC/news/newsIntelligent/domain/topic/service/query/TopicQueryServiceImpl.java @@ -0,0 +1,52 @@ +package UMC.news.newsIntelligent.domain.topic.service.query; + +import UMC.news.newsIntelligent.domain.topic.entity.Topic; +import UMC.news.newsIntelligent.domain.topic.converter.TopicConverter; +import UMC.news.newsIntelligent.domain.topic.dto.TopicResponseDTO; +import UMC.news.newsIntelligent.domain.topic.repository.TopicRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class TopicQueryServiceImpl implements TopicQueryService { + + private final TopicRepository topicRepository; + + @Override + public TopicResponseDTO.TopicPreviewListResDTO searchTopics(String keyword, Long cursor, int size) { + cursor = normalizeCursor(cursor); + size = normalizeSize(size); + + Pageable pageable = PageRequest.of(0, size); + Slice topicSlice = topicRepository.findByKeywordAndCursor(keyword, cursor, pageable); + + List topicList = topicSlice.stream() + .map(TopicConverter::toPreviewResDTO) + .toList(); + + Long nextCursor = topicSlice.hasNext() ? topicList.get(topicList.size() - 1).id() : null; + + return TopicResponseDTO.TopicPreviewListResDTO.builder() + .cursor(nextCursor) + .hasNext(topicSlice.hasNext()) + .topics(topicList) + .build(); + } + + private Long normalizeCursor(Long cursor) { + return (cursor == 0) ? Long.MAX_VALUE : cursor; + } + + // 요청 사이즈가 1보다 작으면 기본값 10, 10보다 크면 최대값 10으로 제한 + private int normalizeSize(int size) { + return (size < 1 || size > 10) ? 10 : size; + } +}