Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 = "게시물 작성",
Expand Down Expand Up @@ -152,4 +153,15 @@ public ResponseEntity<SingleResponse<?>> getBestPosts(@RequestParam("pageNumber"
return ResponseEntity.ok()
.body(new SingleResponse<>(200, "Top3로 선정된 게시글들 모두 반환 완료", postService.getBestPosts(pageNumber)));
}

@Operation(summary = "카테고리별 삭제되지 않은 게시물 조회 - 커서(ObjectId) 기반")
@GetMapping("/category/cursor")
public ResponseEntity<SingleResponse<CursorPageResponse<PostDetailResponseDTO>>> 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))));
}
}
Original file line number Diff line number Diff line change
@@ -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<T> {
private final List<T> items;
private final String nextCursor; // 다음 페이지 커서 값
private final boolean hasNext;
}
Original file line number Diff line number Diff line change
@@ -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<PostEntity>
*/
public List<PostEntity> findByCategoryWithIdCursor(String postCategory, List<ObjectId> 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");
}
}
Original file line number Diff line number Diff line change
@@ -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<PostDetailResponseDTO> 반환
*/
public CursorPageResponse<PostDetailResponseDTO> getAllPostsByCursorIdOnly(PostCategory category, String cursor, int limit) {
List<ObjectId> 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<PostEntity> 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<PostDetailResponseDTO> items = postService.getPostListResponseDtos(batch);
return CursorPageResponse.<PostDetailResponseDTO>builder()
.items(items)
.hasNext(hasNext)
.nextCursor(nextCursor)
.build();
}
}