diff --git a/src/main/java/com/jiwon/mylog/domain/post/controller/PostController.java b/src/main/java/com/jiwon/mylog/domain/post/controller/PostController.java index 50741d3..40b2e4a 100644 --- a/src/main/java/com/jiwon/mylog/domain/post/controller/PostController.java +++ b/src/main/java/com/jiwon/mylog/domain/post/controller/PostController.java @@ -1,5 +1,6 @@ package com.jiwon.mylog.domain.post.controller; +import com.jiwon.mylog.domain.post.dto.response.PostNavigationResponse; import com.jiwon.mylog.domain.post.service.PostService; import com.jiwon.mylog.domain.post.service.PostViewService; import com.jiwon.mylog.global.security.auth.annotation.AllUser; @@ -122,6 +123,24 @@ public ResponseEntity getPosts( return new ResponseEntity<>(response, HttpStatus.OK); } + @GetMapping("/posts/{postId}/navigation") + @Operation( + summary = "현재 게시글의 정보(카테고리 ID, 카테고리 내에서의 현재 page, offset)를 획득한다." + ) + public ResponseEntity getPostNavigation(@PathVariable("postId") Long postId) { + PostNavigationResponse response = postService.getPostNavigation(postId); + return ResponseEntity.ok(response); + } + + @GetMapping("/categories/{categoryId}/posts") + public ResponseEntity getCategorizedPosts( + @PathVariable("categoryId") Long categoryId, + @RequestParam Long userId, + @PageableDefault(size = 5, sort = "createdAt", direction = Direction.DESC) Pageable pageable) { + PageResponse response = postService.getCategorizedPosts(categoryId, userId, pageable); + return ResponseEntity.ok(response); + } + @GetMapping("/users/{userId}/categories/{categoryId}/posts") @Operation( summary = "특정 유저의 카테고리별 게시글 조회 (태그 필터링 및 키워드 검색 포함)", diff --git a/src/main/java/com/jiwon/mylog/domain/post/dto/response/PostDetailResponse.java b/src/main/java/com/jiwon/mylog/domain/post/dto/response/PostDetailResponse.java index c358556..ae1429a 100644 --- a/src/main/java/com/jiwon/mylog/domain/post/dto/response/PostDetailResponse.java +++ b/src/main/java/com/jiwon/mylog/domain/post/dto/response/PostDetailResponse.java @@ -36,6 +36,8 @@ public class PostDetailResponse { private CategoryResponse category; private List tags; private List comments; + private RelatedPostResponse previousPost; + private RelatedPostResponse nextPost; public PostDetailResponse(Long postId, String title, String content, String contentPreview, int views, PostStatus postStatus, Visibility visibility, LocalDateTime createdAt, boolean pinned, PostType type, UserResponse user, CategoryResponse category) { this.postId = postId; @@ -75,6 +77,11 @@ public static PostDetailResponse fromPost(Post post) { .build(); } + public void setRelatedPosts(RelatedPostResponse previous, RelatedPostResponse next) { + this.previousPost = previous; + this.nextPost = next; + } + public void setViews(int views) { this.views = views; } diff --git a/src/main/java/com/jiwon/mylog/domain/post/dto/response/PostNavigationResponse.java b/src/main/java/com/jiwon/mylog/domain/post/dto/response/PostNavigationResponse.java new file mode 100644 index 0000000..ca7fcf8 --- /dev/null +++ b/src/main/java/com/jiwon/mylog/domain/post/dto/response/PostNavigationResponse.java @@ -0,0 +1,17 @@ +package com.jiwon.mylog.domain.post.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Builder +@AllArgsConstructor +@Getter +public class PostNavigationResponse { + private final Long postId; + private final Long userId; + private final Long categoryId; + private final Long currentOffset; + private final Long currentPage; + private final Long totalPosts; +} diff --git a/src/main/java/com/jiwon/mylog/domain/post/dto/response/RelatedPostResponse.java b/src/main/java/com/jiwon/mylog/domain/post/dto/response/RelatedPostResponse.java new file mode 100644 index 0000000..091b013 --- /dev/null +++ b/src/main/java/com/jiwon/mylog/domain/post/dto/response/RelatedPostResponse.java @@ -0,0 +1,20 @@ +package com.jiwon.mylog.domain.post.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class RelatedPostResponse { + private final Long postId; + private final String title; + private final LocalDateTime createdAt; + + public RelatedPostResponse(Long postId, String title, LocalDateTime createdAt) { + this.postId = postId; + this.title = title; + this.createdAt = createdAt; + } +} diff --git a/src/main/java/com/jiwon/mylog/domain/post/repository/PostRepositoryCustom.java b/src/main/java/com/jiwon/mylog/domain/post/repository/PostRepositoryCustom.java index 481be54..54f4de4 100644 --- a/src/main/java/com/jiwon/mylog/domain/post/repository/PostRepositoryCustom.java +++ b/src/main/java/com/jiwon/mylog/domain/post/repository/PostRepositoryCustom.java @@ -1,8 +1,9 @@ package com.jiwon.mylog.domain.post.repository; import com.jiwon.mylog.domain.post.dto.response.PostDetailResponse; +import com.jiwon.mylog.domain.post.dto.response.PostNavigationResponse; import com.jiwon.mylog.domain.post.dto.response.PostSummaryResponse; -import com.jiwon.mylog.domain.post.entity.Post; +import com.jiwon.mylog.domain.post.dto.response.RelatedPostResponse; import com.jiwon.mylog.domain.user.dto.response.UserActivityResponse; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -19,4 +20,8 @@ public interface PostRepositoryCustom { Optional findPostDetail(Long postId); List findUserActivities(Long userId, LocalDate start, LocalDate end); + + PostNavigationResponse findPostNavigation(Long postId); + + Page findCategorizedPosts(Long categoryId, Long userId, Pageable pageable); } diff --git a/src/main/java/com/jiwon/mylog/domain/post/repository/PostRepositoryImpl.java b/src/main/java/com/jiwon/mylog/domain/post/repository/PostRepositoryImpl.java index d52fdee..33ec1ce 100644 --- a/src/main/java/com/jiwon/mylog/domain/post/repository/PostRepositoryImpl.java +++ b/src/main/java/com/jiwon/mylog/domain/post/repository/PostRepositoryImpl.java @@ -7,7 +7,9 @@ import com.jiwon.mylog.domain.image.entity.QProfileImage; import com.jiwon.mylog.domain.like.QLike; import com.jiwon.mylog.domain.post.dto.response.PostDetailResponse; +import com.jiwon.mylog.domain.post.dto.response.PostNavigationResponse; import com.jiwon.mylog.domain.post.dto.response.PostSummaryResponse; +import com.jiwon.mylog.domain.post.dto.response.RelatedPostResponse; import com.jiwon.mylog.domain.post.entity.QPost; import com.jiwon.mylog.domain.tag.dto.response.TagResponse; import com.jiwon.mylog.domain.tag.entity.QPostTag; @@ -17,6 +19,7 @@ import com.jiwon.mylog.domain.user.dto.response.UserSummaryResponse; import com.jiwon.mylog.domain.user.entity.QUser; import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.Tuple; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.DateTemplate; @@ -27,6 +30,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; @@ -47,6 +51,9 @@ public class PostRepositoryImpl implements PostRepositoryCustom { private final JPAQueryFactory jpaQueryFactory; private static final QPost POST = QPost.post; + private static final QPost PREV_POST = new QPost("prev"); + private static final QPost NEXT_POST = new QPost("next"); + private static final QPost SUB_POST = new QPost("sub"); private static final QUser USER = QUser.user; private static final QProfileImage PROFILE_IMAGE = QProfileImage.profileImage; private static final QCategory CATEGORY = QCategory.category; @@ -153,7 +160,21 @@ public Optional findPostDetail(Long postId) { return Optional.empty(); } - List tags = jpaQueryFactory + List tags = getTagsByPostId(postId); + List comments = getCommentsByPostId(postId); + + Tuple postData = getPostData(postId); + RelatedPostResponse previousPost = findPreviousPost(postData); + RelatedPostResponse nextPost = findNextPost(postData); + + postDetailResponse.setTagsAndComments(tags, comments); + postDetailResponse.setRelatedPosts(previousPost, nextPost); + + return Optional.of(postDetailResponse); + } + + private List getTagsByPostId(Long postId) { + return jpaQueryFactory .select(Projections.constructor(TagResponse.class, TAG.id, TAG.name @@ -163,8 +184,10 @@ public Optional findPostDetail(Long postId) { .where(POST_TAG.post.id.eq(postId)) .orderBy(POST_TAG.id.asc()) .fetch(); + } - List comments = jpaQueryFactory + private List getCommentsByPostId(Long postId) { + return jpaQueryFactory .select(Projections.constructor(CommentResponse.class, COMMENT.id, COMMENT.parent.id, @@ -190,9 +213,138 @@ public Optional findPostDetail(Long postId) { .where(COMMENT.post.id.eq(postId)) .orderBy(COMMENT.createdAt.desc()) .fetch(); + } - postDetailResponse.setTagsAndComments(tags, comments); - return Optional.of(postDetailResponse); + @Override + public PostNavigationResponse findPostNavigation(Long postId) { + + Tuple postData = getPostData(postId); + if (postData == null) { + return null; + } + + Long defaultPageSize = 5L; + + Long userId = postData.get(0, Long.class); + Long categoryId = postData.get(1, Long.class); + LocalDateTime createdAt = postData.get(2, LocalDateTime.class); + + BooleanBuilder conditions = createCategorizedDefaultConditions(userId, categoryId); + Long total = createCountQuery(conditions); + Long offset = createCountQuery(conditions.and(POST.createdAt.gt(createdAt))); + + long currentPage = offset / defaultPageSize; + long currentPageOffset = offset - (offset % defaultPageSize); + + return PostNavigationResponse.builder() + .postId(postId) + .userId(userId) + .categoryId(categoryId) + .currentOffset(currentPageOffset) + .currentPage(currentPage) + .totalPosts(total) + .build(); + } + + @Override + public Page findCategorizedPosts(Long categoryId, Long userId, Pageable pageable) { + BooleanBuilder conditions = createCategorizedDefaultConditions(userId, categoryId); + List categorizedPosts = + createdCategorizedPostsQuery(conditions, pageable); + Long total = createCountQuery(conditions); + return new PageImpl<>(categorizedPosts, pageable, total); + } + + private BooleanBuilder createCategorizedDefaultConditions(Long userId, Long categoryId) { + BooleanBuilder conditions = new BooleanBuilder() + .and(POST.user.id.eq(userId)) + .and(POST.deletedAt.isNull()); + + if (categoryId != null && categoryId > 0L) { + conditions.and(POST.category.id.eq(categoryId)); + } else { + conditions.and(POST.category.isNull()); + } + + return conditions; + } + + private List createdCategorizedPostsQuery(BooleanBuilder conditions, Pageable pageable) { + return jpaQueryFactory + .select(Projections.constructor(RelatedPostResponse.class, + POST.id, POST.title, POST.createdAt + ) + ) + .from(POST) + .where(conditions) + .orderBy(POST.createdAt.desc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + } + + private RelatedPostResponse findPreviousPost(Tuple currentPost) { + + if (currentPost == null) { + return null; + } + + Long userId = currentPost.get(0, Long.class); + Long categoryId = currentPost.get(1, Long.class); + LocalDateTime createdAt = currentPost.get(2, LocalDateTime.class); + + BooleanBuilder conditions = + createCategorizedDefaultConditions(userId, categoryId) + .and(POST.createdAt.lt(createdAt)); + + return jpaQueryFactory + .select(Projections.constructor(RelatedPostResponse.class, + POST.id, POST.title, POST.createdAt + ) + ) + .from(POST) + .where(conditions) + .orderBy(POST.createdAt.desc()) + .limit(1) + .fetchOne(); + } + + private RelatedPostResponse findNextPost(Tuple currentPost) { + + if (currentPost == null) { + return null; + } + + Long userId = currentPost.get(0, Long.class); + Long categoryId = currentPost.get(1, Long.class); + LocalDateTime createdAt = currentPost.get(2, LocalDateTime.class); + + BooleanBuilder conditions = + createCategorizedDefaultConditions(userId, categoryId) + .and(POST.createdAt.gt(createdAt)); + + return jpaQueryFactory + .select(Projections.constructor(RelatedPostResponse.class, + POST.id, POST.title, POST.createdAt + ) + ) + .from(POST) + .where(conditions) + .orderBy(POST.createdAt.asc()) + .limit(1) + .fetchOne(); + } + + private Tuple getPostData(Long postId) { + Tuple currentPost = jpaQueryFactory + .select(POST.user.id, + POST.category.id.coalesce(-1L), + POST.createdAt + ) + .from(POST) + .where(POST.id.eq(postId)) + .fetchOne(); + return currentPost; } @Override diff --git a/src/main/java/com/jiwon/mylog/domain/post/service/PostService.java b/src/main/java/com/jiwon/mylog/domain/post/service/PostService.java index 8484111..aa94b32 100644 --- a/src/main/java/com/jiwon/mylog/domain/post/service/PostService.java +++ b/src/main/java/com/jiwon/mylog/domain/post/service/PostService.java @@ -7,6 +7,8 @@ import com.jiwon.mylog.domain.post.dto.response.MainPostResponse; import com.jiwon.mylog.domain.post.dto.response.NoticePostResponse; import com.jiwon.mylog.domain.post.dto.response.PostDetailResponse; +import com.jiwon.mylog.domain.post.dto.response.PostNavigationResponse; +import com.jiwon.mylog.domain.post.dto.response.RelatedPostResponse; import com.jiwon.mylog.global.common.entity.PageResponse; import com.jiwon.mylog.domain.post.dto.response.PostSummaryResponse; import com.jiwon.mylog.domain.post.entity.Post; @@ -196,6 +198,23 @@ public PageResponse getPosts(Pageable pageable) { postPage.getTotalElements()); } + @Transactional(readOnly = true) + public PostNavigationResponse getPostNavigation(Long postId) { + return postRepository.findPostNavigation(postId); + } + + @Transactional(readOnly = true) + public PageResponse getCategorizedPosts(Long categoryId, Long userId, Pageable pageable) { + Page postPage = postRepository.findCategorizedPosts(categoryId, userId, pageable); + return PageResponse.from( + postPage.getContent(), + postPage.getNumber(), + postPage.getSize(), + postPage.getTotalPages(), + postPage.getTotalElements() + ); + } + @Cacheable(value = "post::filter", keyGenerator = "postCacheKeyGenerator") @Transactional(readOnly = true) public PageResponse getFilteredPosts(