From 0f6c3c62b82b88e714086fd7ef9fdc00d36d22f2 Mon Sep 17 00:00:00 2001 From: janghyunjun Date: Sat, 16 Aug 2025 01:48:59 +0900 Subject: [PATCH 01/13] =?UTF-8?q?[feat]=20=ED=8A=B9=EC=A0=95=20=EC=B1=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9E=91=EC=84=B1=ED=95=9C=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?UseCase=20=EC=A0=95=EC=9D=98=20(#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/port/in/FeedRelatedWithBookUseCase.java | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/main/java/konkuk/thip/feed/application/port/in/FeedRelatedWithBookUseCase.java diff --git a/src/main/java/konkuk/thip/feed/application/port/in/FeedRelatedWithBookUseCase.java b/src/main/java/konkuk/thip/feed/application/port/in/FeedRelatedWithBookUseCase.java new file mode 100644 index 000000000..c26ff78a2 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/application/port/in/FeedRelatedWithBookUseCase.java @@ -0,0 +1,9 @@ +package konkuk.thip.feed.application.port.in; + +import konkuk.thip.feed.adapter.in.web.response.FeedRelatedWithBookResponse; +import konkuk.thip.feed.application.port.in.dto.FeedRelatedWithBookQuery; + +public interface FeedRelatedWithBookUseCase { + + FeedRelatedWithBookResponse getFeedsByBook(FeedRelatedWithBookQuery query); +} From 0d3a97652a28d0e74d1d529bedc39a3653512746 Mon Sep 17 00:00:00 2001 From: janghyunjun Date: Sat, 16 Aug 2025 01:49:14 +0900 Subject: [PATCH 02/13] =?UTF-8?q?[feat]=20=ED=8A=B9=EC=A0=95=20=EC=B1=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9E=91=EC=84=B1=ED=95=9C=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=EC=A1=B0=EA=B1=B4=20=EC=A0=95=EC=9D=98=20?= =?UTF-8?q?(#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../in/dto/FeedRelatedWithBookSortType.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/main/java/konkuk/thip/feed/application/port/in/dto/FeedRelatedWithBookSortType.java diff --git a/src/main/java/konkuk/thip/feed/application/port/in/dto/FeedRelatedWithBookSortType.java b/src/main/java/konkuk/thip/feed/application/port/in/dto/FeedRelatedWithBookSortType.java new file mode 100644 index 000000000..6d661b59e --- /dev/null +++ b/src/main/java/konkuk/thip/feed/application/port/in/dto/FeedRelatedWithBookSortType.java @@ -0,0 +1,26 @@ +package konkuk.thip.feed.application.port.in.dto; + +import konkuk.thip.common.exception.InvalidStateException; +import konkuk.thip.common.exception.code.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum FeedRelatedWithBookSortType { + LIKE("like"), + LATEST("latest") + ; + + private final String type; + + public static FeedRelatedWithBookSortType from(String type) { + for (FeedRelatedWithBookSortType sortType : values()) { + if (sortType.getType().equals(type)) { + return sortType; + } + } + throw new InvalidStateException(ErrorCode.API_INVALID_PARAM, + new IllegalArgumentException("정렬 타입은 like 또는 latest 중 하나여야 합니다. 현재 타입: " + type)); + } +} From ae11b9b01cb07b0342a0c53c623955c961451eeb Mon Sep 17 00:00:00 2001 From: janghyunjun Date: Sat, 16 Aug 2025 01:49:21 +0900 Subject: [PATCH 03/13] =?UTF-8?q?[feat]=20=ED=8A=B9=EC=A0=95=20=EC=B1=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9E=91=EC=84=B1=ED=95=9C=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=A0=95=EC=9D=98=20(#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/FeedRelatedWithBookService.java | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 src/main/java/konkuk/thip/feed/application/service/FeedRelatedWithBookService.java diff --git a/src/main/java/konkuk/thip/feed/application/service/FeedRelatedWithBookService.java b/src/main/java/konkuk/thip/feed/application/service/FeedRelatedWithBookService.java new file mode 100644 index 000000000..17cb81365 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/application/service/FeedRelatedWithBookService.java @@ -0,0 +1,78 @@ +package konkuk.thip.feed.application.service; + +import konkuk.thip.book.application.port.out.BookQueryPort; +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; +import konkuk.thip.feed.adapter.in.web.response.FeedRelatedWithBookResponse; +import konkuk.thip.feed.application.mapper.FeedQueryMapper; +import konkuk.thip.feed.application.port.in.FeedRelatedWithBookUseCase; +import konkuk.thip.feed.application.port.in.dto.FeedRelatedWithBookQuery; +import konkuk.thip.feed.application.port.out.FeedQueryPort; +import konkuk.thip.feed.application.port.out.dto.FeedQueryDto; +import konkuk.thip.post.application.port.out.PostLikeQueryPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class FeedRelatedWithBookService implements FeedRelatedWithBookUseCase { + + private final FeedQueryPort feedQueryPort; + private final BookQueryPort bookQueryPort; + private final PostLikeQueryPort postLikeQueryPort; + + private final FeedQueryMapper feedQueryMapper; + + private static final int DEFAULT_PAGE_SIZE = 10; + + @Override + public FeedRelatedWithBookResponse getFeedsByBook(FeedRelatedWithBookQuery query) { + + // 책이 DB에 존재하는지 확인 + boolean isBookExist = bookQueryPort.existsBookByIsbn(query.isbn()); + + // 책이 존재하지 않는 경우 빈 리스트로 응답 + if(!isBookExist) { + return FeedRelatedWithBookResponse.of(List.of(), null, true); + } + + Cursor cursor = Cursor.from(query.cursor(), DEFAULT_PAGE_SIZE); + + // 정렬 조건에 따른 피드 조회 + CursorBasedList feedQueryDtoCursorBasedList = switch(query.sortType()) { + case LIKE -> feedQueryPort.findFeedsByBookIsbnOrderByLike(query.isbn(), query.userId(), cursor); + case LATEST -> feedQueryPort.findFeedsByBookIsbnOrderByLatest(query.isbn(), query.userId(), cursor); + }; + + // 사용자가 저장한 피드 ID를 조회 + Set savedFeedIds = feedQueryPort.findSavedFeedIdsByUserIdAndFeedIds( + feedQueryDtoCursorBasedList.contents().stream() + .map(FeedQueryDto::feedId) + .collect(Collectors.toSet()), + query.userId() + ); + + // 사용자가 좋아요한 피드 ID를 조회 + Set likedFeedIds = postLikeQueryPort.findPostIdsLikedByUser( + feedQueryDtoCursorBasedList.contents().stream() + .map(FeedQueryDto::feedId) + .collect(Collectors.toSet()), + query.userId() + ); + + return FeedRelatedWithBookResponse.of( + feedQueryMapper.toFeedRelatedWithBookDtos( + feedQueryDtoCursorBasedList.contents(), + savedFeedIds, + likedFeedIds, + query.userId() + ), + feedQueryDtoCursorBasedList.nextCursor(), + feedQueryDtoCursorBasedList.isLast() + ); + } +} From 144aa91254c445cb82c0b362168a175cc2a182b2 Mon Sep 17 00:00:00 2001 From: janghyunjun Date: Sat, 16 Aug 2025 01:49:27 +0900 Subject: [PATCH 04/13] =?UTF-8?q?[feat]=20=ED=8A=B9=EC=A0=95=20=EC=B1=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9E=91=EC=84=B1=ED=95=9C=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?dto=20=EC=A0=95=EC=9D=98=20(#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/FeedRelatedWithBookResponse.java | 36 +++++++++++++++++++ .../port/in/dto/FeedRelatedWithBookQuery.java | 12 +++++++ 2 files changed, 48 insertions(+) create mode 100644 src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedRelatedWithBookResponse.java create mode 100644 src/main/java/konkuk/thip/feed/application/port/in/dto/FeedRelatedWithBookQuery.java diff --git a/src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedRelatedWithBookResponse.java b/src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedRelatedWithBookResponse.java new file mode 100644 index 000000000..b9d9a3ed5 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/adapter/in/web/response/FeedRelatedWithBookResponse.java @@ -0,0 +1,36 @@ +package konkuk.thip.feed.adapter.in.web.response; + +import lombok.Builder; + +import java.util.List; + +@Builder +public record FeedRelatedWithBookResponse( + List feeds, + String nextCursor, + boolean isLast +) { + public record FeedRelatedWithBookDto( + Long feedId, + Long creatorId, + boolean isWriter, + String creatorNickname, + String creatorProfileImageUrl, + String aliasName, + String aliasColor, + String postDate, + String isbn, + String bookTitle, + String bookAuthor, + String contentBody, + String[] contentUrls, + int likeCount, + int commentCount, + boolean isSaved, + boolean isLiked + ) {} + + public static FeedRelatedWithBookResponse of(List feeds, String nextCursor, boolean isLast) { + return new FeedRelatedWithBookResponse(feeds, nextCursor, isLast); + } +} diff --git a/src/main/java/konkuk/thip/feed/application/port/in/dto/FeedRelatedWithBookQuery.java b/src/main/java/konkuk/thip/feed/application/port/in/dto/FeedRelatedWithBookQuery.java new file mode 100644 index 000000000..133834361 --- /dev/null +++ b/src/main/java/konkuk/thip/feed/application/port/in/dto/FeedRelatedWithBookQuery.java @@ -0,0 +1,12 @@ +package konkuk.thip.feed.application.port.in.dto; + +import lombok.Builder; + +@Builder +public record FeedRelatedWithBookQuery( + String isbn, + FeedRelatedWithBookSortType sortType, + String cursor, + Long userId +) { +} From 80ff3954d44199cecebf0aa3562b3532ec9aac41 Mon Sep 17 00:00:00 2001 From: janghyunjun Date: Sat, 16 Aug 2025 01:49:42 +0900 Subject: [PATCH 05/13] =?UTF-8?q?[feat]=20FeedQueryDto=20QueryProjection?= =?UTF-8?q?=20=EC=A0=95=EC=9D=98=20(#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../port/out/dto/FeedQueryDto.java | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/src/main/java/konkuk/thip/feed/application/port/out/dto/FeedQueryDto.java b/src/main/java/konkuk/thip/feed/application/port/out/dto/FeedQueryDto.java index 89274a216..b65bd535b 100644 --- a/src/main/java/konkuk/thip/feed/application/port/out/dto/FeedQueryDto.java +++ b/src/main/java/konkuk/thip/feed/application/port/out/dto/FeedQueryDto.java @@ -1,5 +1,6 @@ package konkuk.thip.feed.application.port.out.dto; +import com.querydsl.core.annotations.QueryProjection; import jakarta.annotation.Nullable; import lombok.Builder; @@ -22,4 +23,50 @@ public record FeedQueryDto( int commentCount, boolean isPublic, @Nullable Boolean isPriorityFeed -) { } +) { + @QueryProjection + public FeedQueryDto( + Long feedId, + Long creatorId, + String creatorNickname, + String creatorProfileImageUrl, + String alias, + LocalDateTime createdAt, + String isbn, + String bookTitle, + String bookAuthor, + String contentBody, + String concatenatedContentUrls, // GROUP_CONCAT 결과 + Integer likeCount, + Integer commentCount, + Boolean isPublic, + @Nullable Boolean isPriorityFeed + ) { + this( + feedId, + creatorId, + creatorNickname, + creatorProfileImageUrl, + alias, + createdAt, + isbn, + bookTitle, + bookAuthor, + contentBody, + splitToArray(concatenatedContentUrls), + likeCount == null ? 0 : likeCount, + commentCount == null ? 0 : commentCount, + isPublic, + isPriorityFeed + ); + } + + // Attribute convertor로 바꾸기 전에 임시 메서드 + private static String[] splitToArray(String concatenated) { + if (concatenated == null || concatenated.isEmpty()) return new String[0]; + String[] parts = concatenated.split(","); + for (int i = 0; i < parts.length; i++) parts[i] = parts[i].trim(); + return parts; + } + +} From 441b3071019faa6b514b1f1bcd63856c58024bbc Mon Sep 17 00:00:00 2001 From: janghyunjun Date: Sat, 16 Aug 2025 01:49:55 +0900 Subject: [PATCH 06/13] =?UTF-8?q?[feat]=20=ED=8A=B9=EC=A0=95=20=EC=B1=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9E=91=EC=84=B1=ED=95=9C=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EC=A0=95=EC=9D=98=20(#1?= =?UTF-8?q?39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/in/web/FeedQueryController.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/main/java/konkuk/thip/feed/adapter/in/web/FeedQueryController.java b/src/main/java/konkuk/thip/feed/adapter/in/web/FeedQueryController.java index d6bcdc13c..9fb7dd946 100644 --- a/src/main/java/konkuk/thip/feed/adapter/in/web/FeedQueryController.java +++ b/src/main/java/konkuk/thip/feed/adapter/in/web/FeedQueryController.java @@ -3,6 +3,10 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Pattern; +import konkuk.thip.feed.adapter.in.web.response.FeedRelatedWithBookResponse; +import konkuk.thip.feed.application.port.in.dto.FeedRelatedWithBookQuery; +import konkuk.thip.feed.application.port.in.dto.FeedRelatedWithBookSortType; import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; import konkuk.thip.common.swagger.annotation.ExceptionDescription; @@ -26,6 +30,7 @@ public class FeedQueryController { private final FeedShowUserInfoUseCase feedShowUserInfoUseCase; private final FeedShowSingleUseCase feedShowSingleUseCase; private final FeedShowWriteInfoUseCase feedShowWriteInfoUseCase; + private final FeedRelatedWithBookUseCase feedRelatedWithBookUseCase; @Operation( summary = "피드 전체 조회", @@ -106,4 +111,27 @@ public BaseResponse showSingleFeed( public BaseResponse showFeedWriteInfo() { return BaseResponse.ok(feedShowWriteInfoUseCase.showFeedWriteInfo()); } + + @GetMapping("/feeds/related-books/{isbn}") + @Operation( + summary = "특정 책으로 작성된 피드 조회", + description = "책의 ISBN을 통해 해당 책과 관련된 피드를 조회합니다." + ) + public BaseResponse showFeedsByBook( + @Parameter(description = "책의 ISBN 번호 (13자리 숫자)", example = "9781234567890") + @PathVariable("isbn") @Pattern(regexp = "\\d{13}", message = "ISBN은 13자리 숫자여야 합니다.") final String isbn, + @Parameter(description = "정렬 기준 (like: 좋아요순, latest: 최신순) / 기본 : 좋아요 순", example = "like") + @RequestParam(required = false, defaultValue = "like") final String sort, + @Parameter(description = "커서 (첫번째 요청시 : null, 다음 요청시 : 이전 요청에서 반환받은 nextCursor 값)") + @RequestParam(required = false) final String cursor, + @Parameter(hidden = true) @UserId final Long userId + ) { + return BaseResponse.ok(feedRelatedWithBookUseCase.getFeedsByBook(FeedRelatedWithBookQuery.builder() + .isbn(isbn) + .sortType(FeedRelatedWithBookSortType.from(sort)) + .cursor(cursor) + .userId(userId) + .build()) + ); + } } From 11865c4b50728892015acedec3196e5612a901de Mon Sep 17 00:00:00 2001 From: janghyunjun Date: Sat, 16 Aug 2025 01:50:09 +0900 Subject: [PATCH 07/13] =?UTF-8?q?[feat]=20isbn=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=B1=85=20=EC=A1=B4=EC=9E=AC=EC=97=AC=EB=B6=80=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20(#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/out/persistence/BookQueryPersistenceAdapter.java | 5 +++++ .../out/persistence/repository/BookJpaRepository.java | 2 ++ .../konkuk/thip/book/application/port/out/BookQueryPort.java | 2 ++ 3 files changed, 9 insertions(+) diff --git a/src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java index 26075f1f5..1e77dc93a 100644 --- a/src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java @@ -30,6 +30,11 @@ public boolean existsSavedBookByUserIdAndBookId(Long userId, Long bookId) { return savedBookJpaRepository.existsByUserIdAndBookId(userId, bookId); } + @Override + public boolean existsBookByIsbn(String isbn) { + return bookJpaRepository.existsByIsbn(isbn); + } + @Override public List findSavedBooksByUserId(Long userId) { UserJpaEntity user = userJpaRepository.findById(userId) diff --git a/src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookJpaRepository.java b/src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookJpaRepository.java index 9bca9cf9b..b6cbaf433 100644 --- a/src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookJpaRepository.java +++ b/src/main/java/konkuk/thip/book/adapter/out/persistence/repository/BookJpaRepository.java @@ -24,4 +24,6 @@ public interface BookJpaRepository extends JpaRepository { "AND r.startDate <= CURRENT_TIMESTAMP " + // 진행 중인 방만 조회 (모집 중 / 만료된 방 x) "ORDER BY r.roomPercentage DESC") // 방의 진행률이 높은 순서로 정렬 List findJoiningRoomsBooksByUserId(Long userId); + + boolean existsByIsbn(String isbn); } diff --git a/src/main/java/konkuk/thip/book/application/port/out/BookQueryPort.java b/src/main/java/konkuk/thip/book/application/port/out/BookQueryPort.java index bb9e0ab00..e4f9c8224 100644 --- a/src/main/java/konkuk/thip/book/application/port/out/BookQueryPort.java +++ b/src/main/java/konkuk/thip/book/application/port/out/BookQueryPort.java @@ -8,6 +8,8 @@ public interface BookQueryPort { boolean existsSavedBookByUserIdAndBookId(Long userId, Long bookId); + boolean existsBookByIsbn(String isbn); + List findSavedBooksByUserId(Long userId); List findJoiningRoomsBooksByUserId(Long userId); From 92dcef272e94594174e31ae383352e1763efac69 Mon Sep 17 00:00:00 2001 From: janghyunjun Date: Sat, 16 Aug 2025 01:50:16 +0900 Subject: [PATCH 08/13] =?UTF-8?q?[feat]=20dto=20=EB=A7=A4=ED=8D=BC=20?= =?UTF-8?q?=EC=A0=95=EC=9D=98=20(#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/mapper/FeedQueryMapper.java | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/main/java/konkuk/thip/feed/application/mapper/FeedQueryMapper.java b/src/main/java/konkuk/thip/feed/application/mapper/FeedQueryMapper.java index 5f2681a29..323e3d7ed 100644 --- a/src/main/java/konkuk/thip/feed/application/mapper/FeedQueryMapper.java +++ b/src/main/java/konkuk/thip/feed/application/mapper/FeedQueryMapper.java @@ -23,6 +23,9 @@ ) public interface FeedQueryMapper { + /** + * 피드 전체 조회 응답 DTO 변환 + */ @Mapping(target = "aliasName", source = "dto.alias") @Mapping(target = "aliasColor", expression = "java(Alias.from(dto.alias()).getColor())") @Mapping(target = "isSaved", expression = "java(savedFeedIds.contains(dto.feedId()))") @@ -39,6 +42,9 @@ FeedShowAllResponse.FeedDto toFeedShowAllResponse( @Context Long userId ); + /** + * 내 피드 조회 응답 DTO 변환 + */ @Mapping(target = "postDate", expression = "java(DateUtil.formatBeforeTime(dto.createdAt()))") @Mapping(target = "isWriter", source = "dto.creatorId", qualifiedByName = "isWriter") FeedShowMineResponse.FeedDto toFeedShowMineDto(FeedQueryDto dto, @Context Long userId); @@ -59,6 +65,9 @@ FeedShowByUserResponse.FeedDto toFeedShowByUserResponse( @Context Long userId ); + /** + * 특정 유저의 피드 조회 응답 DTO 변환 + */ @Mapping(target = "creatorId", source = "feedOwner.id") @Mapping(target = "profileImageUrl", source = "feedOwner.alias.imageUrl") @Mapping(target = "nickname", source = "feedOwner.nickname") @@ -70,6 +79,9 @@ FeedShowByUserResponse.FeedDto toFeedShowByUserResponse( @Mapping(target = "latestFollowerProfileImageUrls", source = "latestFollowerProfileImageUrls") FeedShowUserInfoResponse toFeedShowUserInfoResponse(User feedOwner, int totalFeedCount, boolean isFollowing, List latestFollowerProfileImageUrls); + /** + * 피드 상세 조회 응답 DTO 변환 + */ @Mapping(target = "feedId", source = "feed.id") @Mapping(target = "creatorId", source = "feedCreator.id") @Mapping(target = "creatorNickname", source = "feedCreator.nickname") @@ -121,4 +133,43 @@ default List toTagsWithCategoryResult(List toFeedRelatedWithBookDtos( + List dtos, + Set savedFeedIds, + Set likedFeedIds, + @Context Long userId + ) { + return dtos.stream() + .map(dto -> toFeedRelatedWithBookDto(dto, savedFeedIds.contains(dto.feedId()), likedFeedIds.contains(dto.feedId()), userId)) + .collect(Collectors.toList()); + } + } From 983ac2f6120578f0eded94c34271bdc5147049a3 Mon Sep 17 00:00:00 2001 From: janghyunjun Date: Sat, 16 Aug 2025 01:50:25 +0900 Subject: [PATCH 09/13] =?UTF-8?q?[feat]=20=ED=8A=B9=EC=A0=95=20=EC=B1=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9E=91=EC=84=B1=ED=95=9C=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?=EC=BF=BC=EB=A6=AC=20=EC=84=A0=EC=96=B8=20(#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FeedQueryPersistenceAdapter.java | 28 ++++- .../repository/FeedQueryRepository.java | 4 + .../repository/FeedQueryRepositoryImpl.java | 115 +++++++++++++++++- .../application/port/out/FeedQueryPort.java | 7 ++ 4 files changed, 147 insertions(+), 7 deletions(-) diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java index 04385ae67..31edeb679 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java @@ -103,7 +103,6 @@ public CursorBasedList findSpecificUserFeedsByCreatedAt(Long feedO @Override public int countAllFeedsByUserId(Long userId) { - // int 로 강제 형변환 해도 괜찮죠?? return (int) feedJpaRepository.countAllFeedsByUserId(userId, StatusType.ACTIVE); } @@ -153,4 +152,31 @@ public Set findSavedFeedIdsByUserIdAndFeedIds(Set feedIds, Long user public List findAllTags() { return feedJpaRepository.findAllTags(); } + + @Override + public CursorBasedList findFeedsByBookIsbnOrderByLike(String isbn, Long userId, Cursor cursor) { + LocalDateTime lastCreatedAt = cursor.isFirstRequest() ? null : cursor.getLocalDateTime(0); + Integer lastLikeCount = cursor.isFirstRequest() ? null : cursor.getInteger(1); + int size = cursor.getPageSize(); + + List feedQueryDtos = feedJpaRepository.findFeedsByBookIsbnOrderByLikeCount(isbn, userId, lastCreatedAt, lastLikeCount, size); + + return CursorBasedList.of(feedQueryDtos, size, feedQueryDto -> { + Cursor nextCursor = new Cursor(List.of(feedQueryDto.createdAt().toString())); + return nextCursor.toEncodedString(); + }); + } + + @Override + public CursorBasedList findFeedsByBookIsbnOrderByLatest(String isbn, Long userId, Cursor cursor) { + LocalDateTime lastCreatedAt = cursor.isFirstRequest() ? null : cursor.getLocalDateTime(0); + int size = cursor.getPageSize(); + + List feedQueryDtos = feedJpaRepository.findFeedsByBookIsbnOrderByCreatedAt(isbn, userId, lastCreatedAt, size); + + return CursorBasedList.of(feedQueryDtos, size, feedQueryDto -> { + Cursor nextCursor = new Cursor(List.of(feedQueryDto.createdAt().toString())); + return nextCursor.toEncodedString(); + }); + } } diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java index 72c188656..b5dda457b 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepository.java @@ -19,4 +19,8 @@ public interface FeedQueryRepository { List findSpecificUserFeedsByCreatedAt(Long feedOwnerId, LocalDateTime lastCreatedAt, int size); List findAllTags(); + + List findFeedsByBookIsbnOrderByLikeCount(String isbn, Long userId, LocalDateTime lastCreatedAt, Integer lastLikeCount, int size); + + List findFeedsByBookIsbnOrderByCreatedAt(String isbn, Long userId, LocalDateTime lastCreatedAt, int size); } diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java index e30c5bb79..8d939705f 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java @@ -1,17 +1,17 @@ package konkuk.thip.feed.adapter.out.persistence.repository; import com.querydsl.core.Tuple; -import com.querydsl.core.types.dsl.BooleanExpression; -import com.querydsl.core.types.dsl.CaseBuilder; -import com.querydsl.core.types.dsl.Expressions; -import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.core.types.dsl.*; +import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.annotation.Nullable; import konkuk.thip.book.adapter.out.jpa.QBookJpaEntity; import konkuk.thip.common.entity.StatusType; import konkuk.thip.feed.adapter.out.jpa.*; +import konkuk.thip.feed.application.port.out.dto.FeedQueryDto; +import konkuk.thip.feed.application.port.out.dto.QFeedQueryDto; import konkuk.thip.feed.application.port.out.dto.QTagCategoryQueryDto; import konkuk.thip.feed.application.port.out.dto.TagCategoryQueryDto; -import konkuk.thip.feed.application.port.out.dto.FeedQueryDto; import konkuk.thip.room.adapter.out.jpa.QCategoryJpaEntity; import konkuk.thip.user.adapter.out.jpa.QAliasJpaEntity; import konkuk.thip.user.adapter.out.jpa.QFollowingJpaEntity; @@ -227,7 +227,6 @@ public List findAllTags() { .orderBy(c.categoryId.asc(), t.tagId.asc()) //Id 순 정렬 .fetch(); } - private List fetchMyFeedIdsByCreatedAt(Long userId, LocalDateTime lastCreatedAt, int size) { return jpaQueryFactory .select(feed.postId) @@ -283,4 +282,108 @@ private FeedQueryDto toDto(FeedJpaEntity e, Integer priority) { .isPriorityFeed(isPriorityFeed) .build(); } + + /** + * 책 ISBN으로 피드를 조회하고, 좋아요 수 기준으로 정렬하여 페이징 처리 + */ + @Override + public List findFeedsByBookIsbnOrderByLikeCount( + String isbn, + Long userId, + @Nullable LocalDateTime lastCreatedAt, + @Nullable Integer lastLikeCount, + int size + ) { + BooleanExpression where = feedByBooksFilter(isbn, userId); + if (lastLikeCount != null && lastCreatedAt != null) { + // likeCount DESC → createdAt DESC + where = where.and( + feed.likeCount.lt(lastLikeCount) + .or(feed.likeCount.eq(lastLikeCount) + .and(feed.createdAt.lt(lastCreatedAt))) + .or(feed.likeCount.eq(lastLikeCount) + .and(feed.createdAt.eq(lastCreatedAt))) + ); + } + + return jpaQueryFactory + .select(toQueryDto()) + .from(feed) + .join(feed.userJpaEntity, user) + .join(feed.bookJpaEntity, book) + .where(where) + .orderBy(feed.likeCount.desc(), feed.createdAt.desc()) + .limit(size + 1) + .fetch(); + } + + /** + * 책 ISBN으로 피드를 조회하고, 최신순 정렬하여 페이징 처리 + */ + @Override + public List findFeedsByBookIsbnOrderByCreatedAt( + String isbn, + Long userId, + @Nullable LocalDateTime lastCreatedAt, + int size + ) { + BooleanExpression where = feedByBooksFilter(isbn, userId); + if (lastCreatedAt != null) { + // createdAt DESC + where = where.and( + feed.createdAt.lt(lastCreatedAt) + .or(feed.createdAt.eq(lastCreatedAt)) + ); + } + + return jpaQueryFactory + .select(toQueryDto()) + .from(feed) + .join(feed.userJpaEntity, user) + .join(feed.bookJpaEntity, book) + .where(where) + .orderBy(feed.createdAt.desc()) + .limit(size + 1) + .fetch(); + } + + private QFeedQueryDto toQueryDto() { + return new QFeedQueryDto( + feed.postId, + feed.userJpaEntity.userId, + user.nickname, + user.aliasForUserJpaEntity.imageUrl, + user.aliasForUserJpaEntity.value, + feed.createdAt, + book.isbn, + book.title, + book.authorName, + feed.content, + // 서브쿼리로 N:1 방지 + JPAExpressions + .select(contentUrlAggExpr()) + .from(content) + .where(content.postJpaEntity.postId.eq(feed.postId)), + feed.likeCount, + feed.commentCount, + feed.isPublic, + Expressions.nullExpression() + ); + } + + // contentUrl을 GROUP_CONCAT으로 묶어서 반환하는 표현식 + private StringExpression contentUrlAggExpr() { + return Expressions.stringTemplate( + "group_concat({0})", + content.contentUrl + ); + } + + // 필터링 조건: 책 ISBN과 사용자 ID를 제외한 다른 사용자 공개 피드 + private BooleanExpression feedByBooksFilter(String isbn, Long userId) { + return feed.status.eq(StatusType.ACTIVE) + .and(feed.bookJpaEntity.isbn.eq(isbn)) + .and(feed.userJpaEntity.userId.ne(userId)) + .and(feed.isPublic.eq(true)); + } } diff --git a/src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java b/src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java index 7b6eaf660..fc3abf017 100644 --- a/src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java +++ b/src/main/java/konkuk/thip/feed/application/port/out/FeedQueryPort.java @@ -41,4 +41,11 @@ public interface FeedQueryPort { Set findSavedFeedIdsByUserIdAndFeedIds(Set feedIds, Long userId); List findAllTags(); + + /** + * 특정 책으로 작성된 피드 조회 + */ + CursorBasedList findFeedsByBookIsbnOrderByLike(String isbn, Long userId, Cursor cursor); + + CursorBasedList findFeedsByBookIsbnOrderByLatest(String isbn, Long userId, Cursor cursor); } From 5326622e1db5a0d920b2ce4d7bd384e43d3435c4 Mon Sep 17 00:00:00 2001 From: janghyunjun Date: Sat, 16 Aug 2025 02:19:13 +0900 Subject: [PATCH 10/13] =?UTF-8?q?[feat]=20likeCount=20=EC=BB=A4=EC=84=9C?= =?UTF-8?q?=20=EB=AC=B8=EC=9E=90=EC=97=B4=20=EB=B3=80=ED=99=98=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EB=9E=98=ED=8D=BC=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20(#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../thip/feed/application/port/out/dto/FeedQueryDto.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/konkuk/thip/feed/application/port/out/dto/FeedQueryDto.java b/src/main/java/konkuk/thip/feed/application/port/out/dto/FeedQueryDto.java index b65bd535b..66b3f857b 100644 --- a/src/main/java/konkuk/thip/feed/application/port/out/dto/FeedQueryDto.java +++ b/src/main/java/konkuk/thip/feed/application/port/out/dto/FeedQueryDto.java @@ -19,8 +19,8 @@ public record FeedQueryDto( String bookAuthor, String contentBody, String[] contentUrls, - int likeCount, - int commentCount, + Integer likeCount, + Integer commentCount, boolean isPublic, @Nullable Boolean isPriorityFeed ) { From aa17057ea67db29b1228a51cc6f351713480fe6d Mon Sep 17 00:00:00 2001 From: janghyunjun Date: Sat, 16 Aug 2025 02:19:22 +0900 Subject: [PATCH 11/13] =?UTF-8?q?[feat]=20likeCount=20=EC=BB=A4=EC=84=9C?= =?UTF-8?q?=20=EC=9D=B8=EC=BD=94=EB=94=A9=20=EC=B6=94=EA=B0=80=20(#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/out/persistence/FeedQueryPersistenceAdapter.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java index 31edeb679..d5b439301 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/FeedQueryPersistenceAdapter.java @@ -162,7 +162,8 @@ public CursorBasedList findFeedsByBookIsbnOrderByLike(String isbn, List feedQueryDtos = feedJpaRepository.findFeedsByBookIsbnOrderByLikeCount(isbn, userId, lastCreatedAt, lastLikeCount, size); return CursorBasedList.of(feedQueryDtos, size, feedQueryDto -> { - Cursor nextCursor = new Cursor(List.of(feedQueryDto.createdAt().toString())); + Cursor nextCursor = new Cursor(List.of(feedQueryDto.createdAt().toString(), + feedQueryDto.likeCount().toString())); return nextCursor.toEncodedString(); }); } From 4dd26d37ed73f93da3061417e3842cdbf6e331f8 Mon Sep 17 00:00:00 2001 From: janghyunjun Date: Sat, 16 Aug 2025 02:19:28 +0900 Subject: [PATCH 12/13] =?UTF-8?q?[feat]=20=EC=9E=98=EB=AA=BB=EB=90=9C=20?= =?UTF-8?q?=EC=BB=A4=EC=84=9C=20=ED=95=84=ED=84=B0=EB=A7=81=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20(#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../out/persistence/repository/FeedQueryRepositoryImpl.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java index 8d939705f..0beb58bba 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java @@ -301,8 +301,6 @@ public List findFeedsByBookIsbnOrderByLikeCount( feed.likeCount.lt(lastLikeCount) .or(feed.likeCount.eq(lastLikeCount) .and(feed.createdAt.lt(lastCreatedAt))) - .or(feed.likeCount.eq(lastLikeCount) - .and(feed.createdAt.eq(lastCreatedAt))) ); } @@ -332,7 +330,6 @@ public List findFeedsByBookIsbnOrderByCreatedAt( // createdAt DESC where = where.and( feed.createdAt.lt(lastCreatedAt) - .or(feed.createdAt.eq(lastCreatedAt)) ); } From aca5594b7612f28d82865c5f53127e70f6837a45 Mon Sep 17 00:00:00 2001 From: janghyunjun Date: Sat, 16 Aug 2025 02:19:49 +0900 Subject: [PATCH 13/13] =?UTF-8?q?[test]=20=ED=8A=B9=EC=A0=95=20=EC=B1=85?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9E=91=EC=84=B1=EB=90=9C=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=20=EC=A1=B0=ED=9A=8C=20api=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20(#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../in/web/FeedRelatedWithBookApiTest.java | 389 ++++++++++++++++++ 1 file changed, 389 insertions(+) create mode 100644 src/test/java/konkuk/thip/feed/adapter/in/web/FeedRelatedWithBookApiTest.java diff --git a/src/test/java/konkuk/thip/feed/adapter/in/web/FeedRelatedWithBookApiTest.java b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedRelatedWithBookApiTest.java new file mode 100644 index 000000000..e831645d4 --- /dev/null +++ b/src/test/java/konkuk/thip/feed/adapter/in/web/FeedRelatedWithBookApiTest.java @@ -0,0 +1,389 @@ +package konkuk.thip.feed.adapter.in.web; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; +import konkuk.thip.feed.adapter.out.persistence.repository.SavedFeedJpaRepository; +import konkuk.thip.post.adapter.out.jpa.PostLikeJpaEntity; +import konkuk.thip.post.adapter.out.persistence.PostLikeJpaRepository; +import konkuk.thip.room.adapter.out.jpa.CategoryJpaEntity; +import konkuk.thip.room.adapter.out.persistence.repository.category.CategoryJpaRepository; +import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserRole; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.alias.AliasJpaRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("[통합] 특정 책으로 작성된 피드 조회 api 통합 테스트") +@AutoConfigureMockMvc(addFilters = false) +@Transactional +class FeedRelatedWithBookApiTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private AliasJpaRepository aliasJpaRepository; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private CategoryJpaRepository categoryJpaRepository; + + @Autowired + private BookJpaRepository bookJpaRepository; + + @Autowired + private FeedJpaRepository feedJpaRepository; + + @Autowired + private SavedFeedJpaRepository savedFeedJpaRepository; + + @Autowired + private PostLikeJpaRepository postLikeJpaRepository; + + private static final String VALID_ISBN = "9781234567890"; + + @Test + @DisplayName("책 존재하지 않으면 빈 리스트 isLast true nextCursor null 반환") + void getFeedsByBook_book_not_exist_returns_empty() throws Exception { + // when + ResultActions result = mockMvc.perform(get("/feeds/related-books/" + VALID_ISBN) + .requestAttr("userId", 1L) // 임의 사용자 + .contentType(MediaType.APPLICATION_JSON)); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.data.feeds").isArray()) + .andExpect(jsonPath("$.data.feeds").isEmpty()) + .andExpect(jsonPath("$.data.isLast").value(true)) + .andExpect(jsonPath("$.data.nextCursor").doesNotExist()); + } + + @Test + @DisplayName("기본 정렬 like 좋아요순 응답 성공") + void getFeedsByBook_like_default_success() throws Exception { + // given + TestData td = prepareDataForFeeds(); // 사용자 피드 생성 + + // requester가 저장한 피드 지정 + savedFeedJpaRepository.save(TestEntityFactory.createSavedFeed(td.requester, td.publicFeed2)); + + // requester가 좋아요한 피드 지정 + postLikeJpaRepository.save(PostLikeJpaEntity.builder() + .userJpaEntity(td.requester) + .postJpaEntity(td.publicFeed1) + .build()); + + // when + ResultActions result = mockMvc.perform(get("/feeds/related-books/" + td.book.getIsbn()) + .requestAttr("userId", td.requester.getUserId()) + .param("sort", "like") + .contentType(MediaType.APPLICATION_JSON)); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.data.feeds").isArray()) + .andExpect(jsonPath("$.data.isLast").isBoolean()); + + String json = result.andReturn().getResponse().getContentAsString(); + JsonNode root = objectMapper.readTree(json); + JsonNode feeds = root.path("data").path("feeds"); + + assertThat(feeds.size()).isGreaterThan(0); + + // 좋아요 내림차순 검증 + int prevLike = Integer.MAX_VALUE; + for (JsonNode f : feeds) { + int like = f.path("likeCount").asInt(); + assertThat(like).isLessThanOrEqualTo(prevLike); + prevLike = like; + } + + // isWriter false 검증 requester가 아닌 다른 사용자의 피드만 조회 + for (JsonNode f : feeds) { + assertThat(f.path("isWriter").asBoolean()).isFalse(); + } + + // isSaved isLiked 매핑 검증 + // publicFeed2 저장됨 → isSaved true + // publicFeed1 좋아요됨 → isLiked true + boolean hasSavedTrue = false; + boolean hasLikedTrue = false; + boolean hasContentUrls = false; + for (JsonNode f : feeds) { + if (f.path("feedId").asLong() == td.publicFeed2.getPostId()) { + assertThat(f.path("isSaved").asBoolean()).isTrue(); + hasSavedTrue = true; + } + if (f.path("feedId").asLong() == td.publicFeed1.getPostId()) { + assertThat(f.path("isLiked").asBoolean()).isTrue(); + hasLikedTrue = true; + } + // contentUrls 배열 확인 + if (f.has("contentUrls") && f.path("contentUrls").isArray()) { + hasContentUrls = true; + } + } + assertThat(hasSavedTrue).isTrue(); + assertThat(hasLikedTrue).isTrue(); + assertThat(hasContentUrls).isTrue(); + } + + @Test + @DisplayName("정렬 latest 최신순 응답 성공 createdAt 내림차순 검증") + void getFeedsByBook_latest_success() throws Exception { + // given + TestData td = prepareDataForFeeds(); + + // when + ResultActions result = mockMvc.perform(get("/feeds/related-books/" + td.book.getIsbn()) + .requestAttr("userId", td.requester.getUserId()) + .param("sort", "latest") + .contentType(MediaType.APPLICATION_JSON)); + + // then + result.andExpect(status().isOk()); + + String json = result.andReturn().getResponse().getContentAsString(); + JsonNode root = objectMapper.readTree(json); + JsonNode feeds = root.path("data").path("feeds"); + + assertThat(feeds.size()).isGreaterThan(0); + + // createdAt 정렬은 응답 필드에 노출되지 않지만 서버 정렬 결과의 안정성은 likeCount 동일 시 createdAt 보조키로 검증하기 어려우므로 + // 최신 정렬 케이스는 최소 결과 유효성 중심으로 점검 + // 추가 보조 검증 isWriter false 유지 + for (JsonNode f : feeds) { + assertThat(f.path("isWriter").asBoolean()).isFalse(); + } + } + + @Test + @DisplayName("커서 기반 페이징 동작 검증") + void getFeedsByBook_cursor_paging_success() throws Exception { + // given + int totalFeeds = 25; // 총 25개의 피드를 생성 + int pageSize = 10; // 페이지당 10개씩 + int sum = 0; // 피드 개수 합산 검증용 + TestData td = prepareDataManyFeeds(totalFeeds); + + // first page + ResultActions first = mockMvc.perform(get("/feeds/related-books/" + td.book.getIsbn()) + .requestAttr("userId", td.requester.getUserId()) + .param("sort", "like") + .contentType(MediaType.APPLICATION_JSON)); + + first.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feeds").isArray()); + + String firstJson = first.andReturn().getResponse().getContentAsString(); + JsonNode firstRoot = objectMapper.readTree(firstJson); + JsonNode feeds = firstRoot.path("data").path("feeds"); + assertThat(feeds.size()).isEqualTo(pageSize); + sum += feeds.size(); + String nextCursor = firstRoot.path("data").path("nextCursor").asText(null); + boolean isLast = firstRoot.path("data").path("isLast").asBoolean(); + + if (!isLast && nextCursor != null && !nextCursor.isEmpty()) { + // second page + ResultActions second = mockMvc.perform(get("/feeds/related-books/" + td.book.getIsbn()) + .requestAttr("userId", td.requester.getUserId()) + .param("sort", "like") + .param("cursor", nextCursor) + .contentType(MediaType.APPLICATION_JSON)); + + second.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feeds").isArray()); + + String secondJson = second.andReturn().getResponse().getContentAsString(); + JsonNode secondRoot = objectMapper.readTree(secondJson); + JsonNode secondFeeds = secondRoot.path("data").path("feeds"); + assertThat(secondFeeds.size()).isEqualTo(pageSize); + sum += secondFeeds.size(); + nextCursor = secondRoot.path("data").path("nextCursor").asText(null); + isLast = secondRoot.path("data").path("isLast").asBoolean(); + assertThat(isLast).isFalse(); // 아직 마지막 페이지가 아니어야 함 + } + + if (!isLast && nextCursor != null && !nextCursor.isEmpty()) { + // third page + ResultActions third = mockMvc.perform(get("/feeds/related-books/" + td.book.getIsbn()) + .requestAttr("userId", td.requester.getUserId()) + .param("sort", "like") + .param("cursor", nextCursor) + .contentType(MediaType.APPLICATION_JSON)); + + third.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feeds").isArray()); + + String thirdJson = third.andReturn().getResponse().getContentAsString(); + JsonNode thirdRoot = objectMapper.readTree(thirdJson); + JsonNode thirdFeeds = thirdRoot.path("data").path("feeds"); + assertThat(thirdFeeds.size()).isLessThanOrEqualTo(pageSize); + sum += thirdFeeds.size(); + boolean isLastThird = thirdRoot.path("data").path("isLast").asBoolean(); + assertThat(isLastThird).isTrue(); // 마지막 페이지여야 함 + String thirdCursor = thirdRoot.path("data").path("nextCursor").asText(); + assertThat(thirdCursor).isEqualTo("null"); // 다음 커서가 없어야 함 + } + + // 전체 피드 개수 검증 + assertThat(sum).isEqualTo(totalFeeds); + } + + @Test + @DisplayName("비공개 피드 제외 및 자기 자신 피드 제외 검증") + void getFeedsByBook_visibility_and_self_filter() throws Exception { + // given + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createLiteratureAlias()); + + UserJpaEntity requester = userJpaRepository.save(UserJpaEntity.builder() + .oauth2Id("kakao_req") + .nickname("요청자") + .nicknameUpdatedAt(LocalDate.now().minusMonths(7)) + .role(UserRole.USER) + .aliasForUserJpaEntity(alias) + .build()); + + CategoryJpaEntity category = categoryJpaRepository.save(TestEntityFactory.createLiteratureCategory(alias)); + + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN(VALID_ISBN)); + + // 자기 자신 글 두 개 생성 + FeedJpaEntity myPublic = TestEntityFactory.createFeed(requester, book, true); + FeedJpaEntity myPrivate = TestEntityFactory.createFeed(requester, book, false); + feedJpaRepository.saveAll(List.of(myPublic, myPrivate)); + + // 다른 사람 공개 글 하나 생성 + UserJpaEntity other = userJpaRepository.save(TestEntityFactory.createUser(alias, "다른사용자")); + FeedJpaEntity othersPublic = TestEntityFactory.createFeed(other, book, true); + feedJpaRepository.save(othersPublic); + + // when + ResultActions result = mockMvc.perform(get("/feeds/related-books/" + book.getIsbn()) + .requestAttr("userId", requester.getUserId()) + .param("sort", "like") + .contentType(MediaType.APPLICATION_JSON)); + + // then + result.andExpect(status().isOk()); + + String json = result.andReturn().getResponse().getContentAsString(); + JsonNode root = objectMapper.readTree(json); + JsonNode feeds = root.path("data").path("feeds"); + + // 자기 자신 글 제외 비공개 제외로 인해 only othersPublic 만 남아야 함 + assertThat(feeds.size()).isEqualTo(1); + assertThat(feeds.get(0).path("creatorId").asLong()).isEqualTo(other.getUserId()); + assertThat(feeds.get(0).path("isWriter").asBoolean()).isFalse(); + } + + private TestData prepareDataForFeeds() { + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createLiteratureAlias()); + CategoryJpaEntity category = categoryJpaRepository.save(TestEntityFactory.createLiteratureCategory(alias)); + + UserJpaEntity requester = userJpaRepository.save(UserJpaEntity.builder() + .oauth2Id("kakao_req") + .nickname("요청자") + .nicknameUpdatedAt(LocalDate.now().minusMonths(7)) + .role(UserRole.USER) + .aliasForUserJpaEntity(alias) + .build()); + + UserJpaEntity author = userJpaRepository.save(TestEntityFactory.createUser(alias, "작성자")); + + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN(VALID_ISBN)); + + // 공개 피드 두 개 비공개 하나 생성 + FeedJpaEntity publicFeed1 = TestEntityFactory.createFeedWithContents( + author, + book, + List.of("http://img/1.jpg", "http://img/2.jpg"), + true + ); + publicFeed1.updateLikeCount(7); // 좋아요 정렬을 위해 수치 조정 + + FeedJpaEntity publicFeed2 = TestEntityFactory.createFeedWithContents( + author, + book, + List.of("http://img/3.jpg"), + true + ); + publicFeed2.updateLikeCount(3); + + FeedJpaEntity privateFeed = TestEntityFactory.createFeed(author, book, false); + + feedJpaRepository.saveAll(List.of(publicFeed1, publicFeed2, privateFeed)); + + return new TestData(requester, author, book, publicFeed1, publicFeed2); + } + + private TestData prepareDataManyFeeds(int count) { + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + categoryJpaRepository.save(TestEntityFactory.createScienceCategory(alias)); + + UserJpaEntity requester = userJpaRepository.save(UserJpaEntity.builder() + .oauth2Id("kakao_req_many") + .nickname("요청자") + .nicknameUpdatedAt(LocalDate.now().minusMonths(7)) + .role(UserRole.USER) + .aliasForUserJpaEntity(alias) + .build()); + + UserJpaEntity author = userJpaRepository.save(TestEntityFactory.createUser(alias, "작성자")); + + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN(VALID_ISBN)); + + for (int i = 0; i < count; i++) { + FeedJpaEntity f = TestEntityFactory.createFeedWithContents( + author, + book, + List.of("http://img/" + i + ".jpg"), + true + ); + f.updateLikeCount(count - i); // 다양한 likeCount 부여 + feedJpaRepository.save(f); + } + + return new TestData(requester, author, book, null, null); + } + + private record TestData( + UserJpaEntity requester, + UserJpaEntity author, + BookJpaEntity book, + FeedJpaEntity publicFeed1, + FeedJpaEntity publicFeed2 + ) {} +} \ No newline at end of file