diff --git a/src/main/java/inu/codin/codin/domain/post/controller/PostController.java b/src/main/java/inu/codin/codin/domain/post/controller/PostController.java index 395830b5..69dc1d86 100644 --- a/src/main/java/inu/codin/codin/domain/post/controller/PostController.java +++ b/src/main/java/inu/codin/codin/domain/post/controller/PostController.java @@ -6,15 +6,18 @@ import inu.codin.codin.domain.post.dto.request.PostContentUpdateRequestDTO; import inu.codin.codin.domain.post.dto.request.PostCreateRequestDTO; import inu.codin.codin.domain.post.dto.request.PostStatusUpdateRequestDTO; +import inu.codin.codin.domain.post.dto.response.CursorPageResponse; import inu.codin.codin.domain.post.dto.response.PostDetailResponseDTO; import inu.codin.codin.domain.post.dto.response.PostPageResponse; import inu.codin.codin.domain.post.entity.PostCategory; +import inu.codin.codin.domain.post.service.PostCursorService; import inu.codin.codin.domain.post.service.PostService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; @@ -28,13 +31,11 @@ @RequestMapping("/posts") @Validated @Tag(name = "POST API", description = "게시글 API") +@RequiredArgsConstructor public class PostController { private final PostService postService; - - public PostController(PostService postService) { - this.postService = postService; - } + private final PostCursorService postCursorService; @Operation( summary = "게시물 작성", @@ -152,4 +153,15 @@ public ResponseEntity> getBestPosts(@RequestParam("pageNumber" return ResponseEntity.ok() .body(new SingleResponse<>(200, "Top3로 선정된 게시글들 모두 반환 완료", postService.getBestPosts(pageNumber))); } + + @Operation(summary = "카테고리별 삭제되지 않은 게시물 조회 - 커서(ObjectId) 기반") + @GetMapping("/category/cursor") + public ResponseEntity>> getAllPostsCursor( + @RequestParam PostCategory postCategory, + @RequestParam(required = false) String cursor, + @RequestParam(defaultValue = "20") int limit + ) { + return ResponseEntity.ok(new SingleResponse<>(200, "커서 기반 카테고리별 게시물 조회 성공", + postCursorService.getAllPostsByCursorIdOnly(postCategory, cursor, Math.min(limit, 100)))); + } } \ No newline at end of file diff --git a/src/main/java/inu/codin/codin/domain/post/dto/response/CursorPageResponse.java b/src/main/java/inu/codin/codin/domain/post/dto/response/CursorPageResponse.java new file mode 100644 index 00000000..79b5eacf --- /dev/null +++ b/src/main/java/inu/codin/codin/domain/post/dto/response/CursorPageResponse.java @@ -0,0 +1,14 @@ +package inu.codin.codin.domain.post.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class CursorPageResponse { + private final List items; + private final String nextCursor; // 다음 페이지 커서 값 + private final boolean hasNext; +} \ No newline at end of file diff --git a/src/main/java/inu/codin/codin/domain/post/repository/PostReadRepository.java b/src/main/java/inu/codin/codin/domain/post/repository/PostReadRepository.java new file mode 100644 index 00000000..2c044842 --- /dev/null +++ b/src/main/java/inu/codin/codin/domain/post/repository/PostReadRepository.java @@ -0,0 +1,54 @@ +package inu.codin.codin.domain.post.repository; + +import inu.codin.codin.domain.post.entity.PostEntity; +import lombok.RequiredArgsConstructor; +import org.bson.types.ObjectId; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.regex.Pattern; + +@Repository +@RequiredArgsConstructor +public class PostReadRepository { + + private final MongoTemplate mongoTemplate; + + /** + * 커서 기반 MongoDB posts 컬렉션 조회 기능 + * @param postCategory 게시물 카테고리 PostCategory. + * @param blockedUsers 차단된 유저 id 리스트 + * @param cursorId 커서 id - 마지막으로 본 post + * @param limit 페이지 크기 + * @return List + */ + public List findByCategoryWithIdCursor(String postCategory, List blockedUsers, ObjectId cursorId, int limit) { + Criteria base = new Criteria().andOperator( + new Criteria().orOperator( + Criteria.where("deleted_at").is(null), + Criteria.where("deleted_at").exists(false) + ), + Criteria.where("postStatus").is("ACTIVE"), + Criteria.where("postCategory").is(postCategory) + ); + + // 차단 유저가 없을 때 + if (blockedUsers != null && !blockedUsers.isEmpty()) { + base = new Criteria().andOperator(base, Criteria.where("userId").nin(blockedUsers)); + } + // 첫번째 페이지 조회 + if (cursorId != null) { + base = new Criteria().andOperator(base, Criteria.where("_id").lt(cursorId)); + } + + Query query = new Query(base) + .with(Sort.by(Sort.Direction.DESC, "_id")) + .limit(limit + 1); + + return mongoTemplate.find(query, PostEntity.class, "posts"); + } +} \ No newline at end of file diff --git a/src/main/java/inu/codin/codin/domain/post/service/PostCursorService.java b/src/main/java/inu/codin/codin/domain/post/service/PostCursorService.java new file mode 100644 index 00000000..5f6b2d22 --- /dev/null +++ b/src/main/java/inu/codin/codin/domain/post/service/PostCursorService.java @@ -0,0 +1,62 @@ +package inu.codin.codin.domain.post.service; + +import inu.codin.codin.common.util.ObjectIdUtil; +import inu.codin.codin.domain.block.service.BlockService; +import inu.codin.codin.domain.post.dto.response.CursorPageResponse; +import inu.codin.codin.domain.post.dto.response.PostDetailResponseDTO; +import inu.codin.codin.domain.post.entity.PostCategory; +import inu.codin.codin.domain.post.entity.PostEntity; +import inu.codin.codin.domain.post.repository.PostReadRepository; +import lombok.RequiredArgsConstructor; +import org.bson.types.ObjectId; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class PostCursorService { + + private final PostReadRepository postReadRepository; + private final BlockService blockService; + private final PostService postService; + + /** + * 커서 기반 조회 서비스 로직 + * @param category 게시글 카테고리 + * @param cursor 마지막 커서 id + * @param limit 조회 개수 + * @return CursorPageResponse 반환 + */ + public CursorPageResponse getAllPostsByCursorIdOnly(PostCategory category, String cursor, int limit) { + List blockedUsers = blockService.getBlockedUsers(); + + // objectId가 24자리 16진수 검증 + ObjectId lastId = null; + if (cursor != null && cursor.matches("^[a-fA-F0-9]{24}$")) { + lastId = ObjectIdUtil.toObjectId(cursor); + } else if (cursor != null && !cursor.isBlank()) { + throw new IllegalArgumentException("cursor는 24자리 hex(ObjectId)여야 합니다."); + } + + List batch = postReadRepository.findByCategoryWithIdCursor(category.name(), blockedUsers, lastId, limit); + + // 다음 페이지 계산 + boolean hasNext = batch.size() > limit; + if (hasNext) batch = batch.subList(0, limit); + + // 다음 커서 id 생성 + String nextCursor = null; + if (hasNext && !batch.isEmpty()) { + PostEntity tail = batch.get(batch.size() - 1); + nextCursor = ObjectIdUtil.toString(tail.get_id()); + } + + List items = postService.getPostListResponseDtos(batch); + return CursorPageResponse.builder() + .items(items) + .hasNext(hasNext) + .nextCursor(nextCursor) + .build(); + } +} \ No newline at end of file