From 06f72bccd4c71f4570fce3c333be72dc888f4973 Mon Sep 17 00:00:00 2001 From: ica Date: Mon, 2 Feb 2026 15:32:14 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=EB=A7=88=EC=9D=B4=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EB=8B=A4=EB=A5=B8=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EC=9D=98=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=84=9C=EC=A0=9C,?= =?UTF-8?q?=EB=B6=81=EB=A1=9C=EA=B7=B8=20=EC=A0=9C=EC=99=B8=ED=95=9C=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20GET=20api=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/UsersProfileController.java | 67 +++++++++++++++++++ .../domain/users/dto/UserProfileResponse.java | 21 ++++++ .../UserProfileQueryRepository.java | 64 ++++++++++++++++++ .../UserProfileSummaryProjection.java | 20 ++++++ .../users/service/UserProfileService.java | 52 ++++++++++++++ .../global/common/apiPayload/ApiResponse.java | 14 ++++ 6 files changed, 238 insertions(+) create mode 100644 booklog/src/main/java/com/example/booklog/domain/users/controller/UsersProfileController.java create mode 100644 booklog/src/main/java/com/example/booklog/domain/users/dto/UserProfileResponse.java create mode 100644 booklog/src/main/java/com/example/booklog/domain/users/repository/UserProfileQueryRepository.java create mode 100644 booklog/src/main/java/com/example/booklog/domain/users/repository/projection/UserProfileSummaryProjection.java create mode 100644 booklog/src/main/java/com/example/booklog/domain/users/service/UserProfileService.java diff --git a/booklog/src/main/java/com/example/booklog/domain/users/controller/UsersProfileController.java b/booklog/src/main/java/com/example/booklog/domain/users/controller/UsersProfileController.java new file mode 100644 index 0000000..f47c2f2 --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/users/controller/UsersProfileController.java @@ -0,0 +1,67 @@ +package com.example.booklog.domain.users.controller; + +import com.example.booklog.domain.users.dto.UserProfileResponse; +import com.example.booklog.domain.users.service.UserProfileService; +import com.example.booklog.global.auth.security.CustomUserDetails; +import com.example.booklog.global.common.apiPayload.ApiResponse; +import com.example.booklog.global.common.apiPayload.code.status.SuccessStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag( + name = "유저 프로필", + description = "다른 유저 프로필(요약/카운트/팔로잉 여부) 조회 API" +) +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/users") +public class UsersProfileController { + + private final UserProfileService userProfileService; + + @Operation( + summary = "다른 유저 프로필 조회", + description = """ + 다른 유저의 프로필 정보를 조회합니다. + - 인증: Access Token(Bearer) + - PathVariable: userId (조회 대상 유저) + - 응답: + - 유저 기본 정보(닉네임/이메일/프로필 이미지 등) + - 카운트(팔로워/팔로잉/저장한 책/완독/북로그/북마크) + - 팔로잉 여부(isFollowing): 로그인한 사용자(me)가 대상 유저를 팔로우 중인지 여부 + """ + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = UserProfileResponse.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "404", + description = "대상 유저를 찾을 수 없음" + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "401", + description = "인증 실패(토큰 없음/만료/유효하지 않음)" + ) + }) + @GetMapping("/{userId}/profile") + public ApiResponse getUserProfile( + @AuthenticationPrincipal CustomUserDetails principal, + @Parameter(description = "조회 대상 유저 ID", example = "42", required = true) + @PathVariable Long userId + ) { + Long meId = principal.getUserId(); // 로그인한 사용자 ID + UserProfileResponse data = userProfileService.getProfile(meId, userId); // 조회 대상 userId + + return ApiResponse.onSuccess(SuccessStatus.OK, data); + } +} diff --git a/booklog/src/main/java/com/example/booklog/domain/users/dto/UserProfileResponse.java b/booklog/src/main/java/com/example/booklog/domain/users/dto/UserProfileResponse.java new file mode 100644 index 0000000..3ff6c07 --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/users/dto/UserProfileResponse.java @@ -0,0 +1,21 @@ +package com.example.booklog.domain.users.dto; + +public record UserProfileResponse( + Long userId, + String nickname, + String email, + String avatarUrl, + + long followerCount, + long followingCount, + + long savedBookCount, // ✅ user_books 전체 권수 (상태 무관) + long completedBookCount, // (UI에 있으면) 완독 수 + long booklogCount, // ✅ 작성한 북로그 수 (posts) + long bookmarkCount, // ✅ 북마크 수 (post_bookmarks: user가 한 북마크) + + boolean isFollowing, // me -> target 팔로잉 여부 + + boolean isShelfPublic, // 공개 토글(프로필에서 보여주면) + boolean isBooklogPublic +) {} diff --git a/booklog/src/main/java/com/example/booklog/domain/users/repository/UserProfileQueryRepository.java b/booklog/src/main/java/com/example/booklog/domain/users/repository/UserProfileQueryRepository.java new file mode 100644 index 0000000..929fe59 --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/users/repository/UserProfileQueryRepository.java @@ -0,0 +1,64 @@ +package com.example.booklog.domain.users.repository; + +import com.example.booklog.domain.users.entity.Users; +import com.example.booklog.domain.users.repository.projection.UserProfileSummaryProjection; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface UserProfileQueryRepository extends JpaRepository { + + @Query(value = """ + SELECT + u.user_id AS userId, + u.nickname AS nickname, + ( + SELECT aa.email + FROM auth_accounts aa + WHERE aa.user_id = u.user_id + ORDER BY aa.id ASC + LIMIT 1 + ) AS email, + u.profile_image_url AS avatarUrl, + + (SELECT COUNT(*) FROM user_follows f WHERE f.followee_id = u.user_id) AS followerCount, + (SELECT COUNT(*) FROM user_follows f WHERE f.follower_id = u.user_id) AS followingCount, + + (SELECT COUNT(*) FROM user_books ub WHERE ub.user_id = u.user_id) AS savedBookCount, + (SELECT COUNT(*) FROM user_books ub WHERE ub.user_id = u.user_id AND ub.status = 'COMPLETED') AS completedBookCount, + + (SELECT COUNT(*) FROM booklog_posts bp WHERE bp.user_id = u.user_id) AS booklogCount, + (SELECT COUNT(*) FROM booklog_bookmark bb WHERE bb.user_id = u.user_id) AS bookmarkCount, + + CASE + WHEN EXISTS( + SELECT 1 + FROM user_follows f2 + WHERE f2.follower_id = :meId + AND f2.followee_id = u.user_id + ) + THEN TRUE + ELSE FALSE + END AS isFollowing, + + CASE + WHEN COALESCE(us.is_shelf_public, 0) = 1 THEN TRUE + ELSE FALSE + END AS isShelfPublic, + + CASE + WHEN COALESCE(us.is_post_public, 0) = 1 THEN TRUE + ELSE FALSE + END AS isBooklogPublic + + FROM users u + LEFT JOIN user_settings us ON us.user_id = u.user_id + WHERE u.user_id = :targetUserId + """, nativeQuery = true) + Optional findUserProfileSummary( + @Param("meId") Long meId, + @Param("targetUserId") Long targetUserId + ); +} diff --git a/booklog/src/main/java/com/example/booklog/domain/users/repository/projection/UserProfileSummaryProjection.java b/booklog/src/main/java/com/example/booklog/domain/users/repository/projection/UserProfileSummaryProjection.java new file mode 100644 index 0000000..404e299 --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/users/repository/projection/UserProfileSummaryProjection.java @@ -0,0 +1,20 @@ +package com.example.booklog.domain.users.repository.projection; + +public interface UserProfileSummaryProjection { + Long getUserId(); + String getNickname(); + String getEmail(); + String getAvatarUrl(); + + Long getFollowerCount(); + Long getFollowingCount(); + Long getSavedBookCount(); + Long getCompletedBookCount(); + Long getBooklogCount(); + Long getBookmarkCount(); + + // ✅ 여기 3개를 Boolean -> Long + Long getIsFollowing(); + Long getIsShelfPublic(); + Long getIsBooklogPublic(); +} diff --git a/booklog/src/main/java/com/example/booklog/domain/users/service/UserProfileService.java b/booklog/src/main/java/com/example/booklog/domain/users/service/UserProfileService.java new file mode 100644 index 0000000..224f3aa --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/users/service/UserProfileService.java @@ -0,0 +1,52 @@ +package com.example.booklog.domain.users.service; + +import com.example.booklog.domain.users.dto.UserProfileResponse; +import com.example.booklog.domain.users.repository.UserProfileQueryRepository; +import com.example.booklog.domain.users.repository.projection.UserProfileSummaryProjection; +import com.example.booklog.global.common.apiPayload.code.status.ErrorStatus; +import com.example.booklog.global.common.apiPayload.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserProfileService { + + private final UserProfileQueryRepository userProfileQueryRepository; + + public UserProfileResponse getProfile(Long meId, Long targetUserId) { + UserProfileSummaryProjection p = userProfileQueryRepository + .findUserProfileSummary(meId, targetUserId) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + + return new UserProfileResponse( + p.getUserId(), + p.getNickname(), + p.getEmail(), + p.getAvatarUrl(), + + safeLong(p.getFollowerCount()), + safeLong(p.getFollowingCount()), + + safeLong(p.getSavedBookCount()), + safeLong(p.getCompletedBookCount()), + safeLong(p.getBooklogCount()), + safeLong(p.getBookmarkCount()), + + // ✅ 0/1(Long) -> boolean + toBool(p.getIsFollowing()), + toBool(p.getIsShelfPublic()), + toBool(p.getIsBooklogPublic()) + ); + } + + private long safeLong(Long v) { + return v == null ? 0L : v; + } + + private boolean toBool(Long v) { + return v != null && v == 1L; + } +} diff --git a/booklog/src/main/java/com/example/booklog/global/common/apiPayload/ApiResponse.java b/booklog/src/main/java/com/example/booklog/global/common/apiPayload/ApiResponse.java index 44a0470..4f05f80 100644 --- a/booklog/src/main/java/com/example/booklog/global/common/apiPayload/ApiResponse.java +++ b/booklog/src/main/java/com/example/booklog/global/common/apiPayload/ApiResponse.java @@ -3,6 +3,7 @@ import com.example.booklog.global.auth.exception.AuthErrorCode; import com.example.booklog.global.auth.exception.AuthSuccessCode; import com.example.booklog.global.common.apiPayload.code.BaseErrorCode; +import com.example.booklog.global.common.apiPayload.code.BaseSuccessCode; import com.example.booklog.global.common.apiPayload.code.generalStatus.GeneralErrorCode; import com.fasterxml.jackson.annotation.JsonInclude; import io.swagger.v3.oas.annotations.media.Schema; @@ -41,6 +42,17 @@ public static ApiResponse onSuccess(AuthSuccessCode successCode) { return new ApiResponse<>(true, successCode.getCode(), successCode.getMessage(), null); } + // 성공 응답 (BaseSuccessCode 사용 - SuccessStatus 등) + public static ApiResponse onSuccess(BaseSuccessCode successCode, T data) { + return new ApiResponse<>(true, successCode.getCode(), successCode.getMessage(), data); + } + + // 성공 응답 (BaseSuccessCode 사용, 데이터 없이) + public static ApiResponse onSuccess(BaseSuccessCode successCode) { + return new ApiResponse<>(true, successCode.getCode(), successCode.getMessage(), null); + } + + // 실패 응답 (AuthErrorCode 사용) public static ApiResponse onFailure(AuthErrorCode errorCode, T data) { return new ApiResponse<>(false, errorCode.getCode(), errorCode.getMessage(), data); @@ -70,4 +82,6 @@ public static ApiResponse onFailure(BaseErrorCode errorCode, T data) { public static ApiResponse onFailure(BaseErrorCode errorCode) { return new ApiResponse<>(false, errorCode.getCode(), errorCode.getMessage(), null); } + + } From 689857d25f4eaeb4c434bcebf0b0b4ca0763177c Mon Sep 17 00:00:00 2001 From: ica Date: Mon, 2 Feb 2026 16:18:06 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=EC=9D=98=20=EC=84=9C=EC=9E=AC=20=EB=B0=8F=20=EB=8F=84=EC=84=9C?= =?UTF-8?q?=20=ED=99=95=EC=9D=B8=20api=20=EA=B5=AC=ED=98=84=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/BookshelfItemsRepository.java | 27 ++- .../repository/BookshelvesRepository.java | 6 + .../UserPublicShelvesController.java | 65 +++++++ .../dto/UserPublicShelfListResponse.java | 39 ++++ .../service/UserPublicShelvesService.java | 170 ++++++++++++++++++ 5 files changed, 300 insertions(+), 7 deletions(-) create mode 100644 booklog/src/main/java/com/example/booklog/domain/users/controller/UserPublicShelvesController.java create mode 100644 booklog/src/main/java/com/example/booklog/domain/users/dto/UserPublicShelfListResponse.java create mode 100644 booklog/src/main/java/com/example/booklog/domain/users/service/UserPublicShelvesService.java diff --git a/booklog/src/main/java/com/example/booklog/domain/library/shelves/repository/BookshelfItemsRepository.java b/booklog/src/main/java/com/example/booklog/domain/library/shelves/repository/BookshelfItemsRepository.java index 9e34327..3837c9e 100644 --- a/booklog/src/main/java/com/example/booklog/domain/library/shelves/repository/BookshelfItemsRepository.java +++ b/booklog/src/main/java/com/example/booklog/domain/library/shelves/repository/BookshelfItemsRepository.java @@ -16,7 +16,7 @@ public interface BookshelfItemsRepository extends JpaRepository findBookIdsByShelfId(@Param("shelfId") Long shelfId); - /** (선택) 특정 서재에 담긴 BookshelfItems 전체 */ + /** 특정 서재에 담긴 BookshelfItems 전체 */ @Query("select bi from BookshelfItems bi where bi.shelf.id = :shelfId") List findAllByShelfId(@Param("shelfId") Long shelfId); @@ -34,7 +34,7 @@ public interface BookshelfItemsRepository extends JpaRepository bookIds); @@ -44,18 +44,31 @@ public interface BookshelfItemsRepository extends JpaRepository bookIds); - /** (선택) 특정 책 1권을 모든 서재에서 제거 */ + /** 특정 책 1권을 모든 서재에서 제거 */ @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("delete from BookshelfItems bi where bi.book.id = :bookId") int deleteByBookId(@Param("bookId") Long bookId); + // ------------------------ + // Query (for public shelves) + // ------------------------ - /** ✅ 서재 프리뷰 3권(최근 담은 순) - book 즉시 로딩 */ + /** ✅ (추가) 서재 카드 프리뷰용: 최근 담은 책 3권만 조회 (book 즉시 로딩) */ @EntityGraph(attributePaths = "book") - List findByShelf_IdOrderByAddedAtDesc(Long shelfId); + List findTop3ByShelf_IdOrderByAddedAtDesc(Long shelfId); + + /** ✅ (추가) 서재 카드에 "n권" 표시용: 해당 서재의 전체 도서 수 */ + long countByShelf_Id(Long shelfId); - /** ✅ 서재 비우기(UNASSIGN 정책에서 사용) */ - int deleteByShelf_Id(Long shelfId); + /** ✅ (추가) 전체보기 정렬(최신순)용: 서재의 전체 도서 목록을 최근 담은 순으로 조회 */ + @EntityGraph(attributePaths = "book") + List findByShelf_IdOrderByAddedAtDesc(Long shelfId); + /** ✅ (추가) 전체보기 정렬(오래된순)용: 서재의 전체 도서 목록을 오래된 순으로 조회 */ + @EntityGraph(attributePaths = "book") + List findByShelf_IdOrderByAddedAtAsc(Long shelfId); + /** ✅ (추가) 전체보기 정렬(제목순)용: 서재의 전체 도서 목록을 제목 오름차순으로 조회 */ + @EntityGraph(attributePaths = "book") + List findByShelf_IdOrderByBook_TitleAsc(Long shelfId); } diff --git a/booklog/src/main/java/com/example/booklog/domain/library/shelves/repository/BookshelvesRepository.java b/booklog/src/main/java/com/example/booklog/domain/library/shelves/repository/BookshelvesRepository.java index 7901e67..0beaef6 100644 --- a/booklog/src/main/java/com/example/booklog/domain/library/shelves/repository/BookshelvesRepository.java +++ b/booklog/src/main/java/com/example/booklog/domain/library/shelves/repository/BookshelvesRepository.java @@ -13,4 +13,10 @@ public interface BookshelvesRepository extends JpaRepository Optional findByIdAndUser_Id(Long shelfId, Long userId); boolean existsByUser_IdAndName(Long userId, String name); + + /** ✅ (추가) 다른 유저 공개 서재 목록 조회 */ + List findByUser_IdAndIsPublicTrueOrderByIdAsc(Long userId); + + /** ✅ (추가) 다른 유저 공개 서재 접근 검증(소유 + 공개) */ + boolean existsByIdAndUser_IdAndIsPublicTrue(Long shelfId, Long userId); } diff --git a/booklog/src/main/java/com/example/booklog/domain/users/controller/UserPublicShelvesController.java b/booklog/src/main/java/com/example/booklog/domain/users/controller/UserPublicShelvesController.java new file mode 100644 index 0000000..e3f3652 --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/users/controller/UserPublicShelvesController.java @@ -0,0 +1,65 @@ +package com.example.booklog.domain.users.controller; + +import com.example.booklog.domain.library.shelves.service.UserPublicShelvesService; +import com.example.booklog.domain.library.shelves.service.UserPublicShelvesService.PublicShelfBookSort; +import com.example.booklog.domain.library.shelves.service.UserPublicShelvesService.PublicShelfBooksResponse; +import com.example.booklog.domain.library.shelves.service.UserPublicShelvesService.PublicShelfListResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.*; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@Tag( + name = "다른 유저 공개 서재", + description = "다른 유저의 공개 서재 목록/서재 도서 목록 조회 API " +) +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/users/{userId}/shelves") +public class UserPublicShelvesController { + + private final UserPublicShelvesService userPublicShelvesService; + + @Operation( + summary = "다른 유저 공개 서재 목록 + 서재별 top3", + description = """ + 다른 유저의 서재 중 isPublic=true 서재만 반환합니다. + 각 서재 카드에는 최근 담은 책 3권(사진/출판사/저자)을 포함합니다. + - 인증: 필요 없음(공개 서재만 조회) + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse(responseCode = "404", description = "유저 없음 또는 공개 서재 없음") + }) + @GetMapping + public PublicShelfListResponse listPublicShelves( + @PathVariable Long userId + ) { + return userPublicShelvesService.listPublicShelves(userId); + } + + @Operation( + summary = "공개 서재의 전체 도서 목록(정렬만)", + description = """ + 특정 공개 서재의 전체 도서 목록을 반환합니다. + - Query: + - sort: LATEST/OLDEST/TITLE/AUTHOR (기본 LATEST) + - 응답 항목: 사진(thumbnailUrl), 출판사(publisherName), 저자(authorName) + - 인증: 필요 없음(공개 서재만 조회) + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse(responseCode = "404", description = "서재 없음 또는 비공개") + }) + @GetMapping("/{shelfId}/books") + public PublicShelfBooksResponse listPublicShelfBooks( + @PathVariable Long userId, + @PathVariable Long shelfId, + @RequestParam(defaultValue = "LATEST") PublicShelfBookSort sort + ) { + return userPublicShelvesService.listPublicShelfBooks(userId, shelfId, sort); + } +} diff --git a/booklog/src/main/java/com/example/booklog/domain/users/dto/UserPublicShelfListResponse.java b/booklog/src/main/java/com/example/booklog/domain/users/dto/UserPublicShelfListResponse.java new file mode 100644 index 0000000..85b3ed1 --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/users/dto/UserPublicShelfListResponse.java @@ -0,0 +1,39 @@ +package com.example.booklog.domain.users.dto; + +import java.util.List; + +/** 공개 서재 목록 응답(대표 3권 포함) */ +public record UserPublicShelfListResponse( + int totalCount, + List items +) {} + +/** 서재 카드 1개 */ +record UserPublicShelfItem( + Long shelfId, + String name, + int bookCount, + List topBooks +) {} + +/** 서재 카드에 보여줄 책 프리뷰 1개 */ +record ShelfBookPreview( + Long bookId, + String thumbnailUrl, + String publisherName, + String authorName +) {} + +/** 특정 서재 도서 전체 목록 응답(페이징 없음) */ +record UserPublicShelfBooksResponse( + int totalCount, + List items +) {} + +/** 서재 내 도서 1개(상태 없음) */ +record UserPublicShelfBookItem( + Long bookId, + String thumbnailUrl, + String publisherName, + String authorName +) {} diff --git a/booklog/src/main/java/com/example/booklog/domain/users/service/UserPublicShelvesService.java b/booklog/src/main/java/com/example/booklog/domain/users/service/UserPublicShelvesService.java new file mode 100644 index 0000000..451639f --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/users/service/UserPublicShelvesService.java @@ -0,0 +1,170 @@ +package com.example.booklog.domain.library.shelves.service; + +import com.example.booklog.domain.library.shelves.entity.BookshelfItems; +import com.example.booklog.domain.library.shelves.entity.Bookshelves; +import com.example.booklog.domain.library.shelves.repository.BookshelfItemsRepository; +import com.example.booklog.domain.library.shelves.repository.BookshelvesRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Comparator; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UserPublicShelvesService { + + private final BookshelvesRepository bookshelvesRepository; + private final BookshelfItemsRepository bookshelfItemsRepository; + + /** 다른 유저 공개 서재 목록 + 서재별 top3 프리뷰 */ + public PublicShelfListResponse listPublicShelves(Long userId) { + + List shelves = bookshelvesRepository.findByUser_IdAndIsPublicTrueOrderByIdAsc(userId); + + List items = shelves.stream() + .map(shelf -> { + long bookCount = bookshelfItemsRepository.countByShelf_Id(shelf.getId()); + + // ✅ top3 프리뷰 + List top3 = bookshelfItemsRepository + .findTop3ByShelf_IdOrderByAddedAtDesc(shelf.getId()) + .stream() + .map(this::toPreview) + .toList(); + + return new PublicShelfItem( + shelf.getId(), + shelf.getName(), + (int) bookCount, + top3 + ); + }) + .toList(); + + return new PublicShelfListResponse(items.size(), items); + } + + /** 특정 공개 서재의 전체 도서 목록(상태 없음, 정렬만) */ + public PublicShelfBooksResponse listPublicShelfBooks(Long userId, Long shelfId, PublicShelfBookSort sort) { + + // ✅ 공개 서재 검증 + boolean ok = bookshelvesRepository.existsByIdAndUser_IdAndIsPublicTrue(shelfId, userId); + if (!ok) { + // 너희 프로젝트 예외 코드로 바꿔도 됨 (SHELF_NOT_FOUND / PRIVATE 등) + throw new IllegalArgumentException("SHELF_NOT_FOUND_OR_PRIVATE"); + } + + List rows = switch (sort) { + case OLDEST -> bookshelfItemsRepository.findByShelf_IdOrderByAddedAtAsc(shelfId); + case TITLE -> bookshelfItemsRepository.findByShelf_IdOrderByBook_TitleAsc(shelfId); + case AUTHOR -> bookshelfItemsRepository.findByShelf_IdOrderByAddedAtDesc(shelfId); // 가져온 뒤 자바 정렬 + default -> bookshelfItemsRepository.findByShelf_IdOrderByAddedAtDesc(shelfId); // LATEST + }; + + // ✅ AUTHOR는 자바에서 정렬 + if (sort == PublicShelfBookSort.AUTHOR) { + rows = rows.stream() + .sorted(Comparator + .comparing((BookshelfItems bi) -> normalize(getPrimaryAuthorName(bi))) + .thenComparing(BookshelfItems::getAddedAt, Comparator.reverseOrder()) + ) + .toList(); + } + + List items = rows.stream() + .map(this::toItem) + .toList(); + + return new PublicShelfBooksResponse(items.size(), items); + } + + // ----------------------- + // mapping helpers + // ----------------------- + + private PublicBookPreview toPreview(BookshelfItems bi) { + var b = bi.getBook(); + return new PublicBookPreview( + b.getId(), + b.getThumbnailUrl(), + b.getPublisherName(), + getPrimaryAuthorName(bi) + ); + } + + private PublicBookItem toItem(BookshelfItems bi) { + var b = bi.getBook(); + return new PublicBookItem( + b.getId(), + b.getThumbnailUrl(), + b.getPublisherName(), + getPrimaryAuthorName(bi) + ); + } + + /** + * ✅ 대표 저자명 가져오기 + * - 너희 엔티티 구조에 맞게 구현해줘야 함 + * - BookAuthors 같은 매핑 엔티티가 있으면 authorOrder=1 을 대표로 쓰는 형태가 일반적 + */ + private String getPrimaryAuthorName(BookshelfItems bi) { + var book = bi.getBook(); + + // TODO: 아래 중 너희 구조에 맞는 걸로 구현 + // 예시) book.getBookAuthors()가 있으면: + // return book.getBookAuthors().stream() + // .sorted(Comparator.comparingInt(BookAuthors::getAuthorOrder)) + // .map(ba -> ba.getAuthor().getName()) + // .findFirst().orElse(null); + + // 예시) book.getAuthors()가 List이면: + // return book.getAuthors().isEmpty() ? null : book.getAuthors().get(0); + + return null; + } + + private String normalize(String s) { + if (s == null) return "\uFFFF"; // null은 뒤로 보내기 + return s.trim().toLowerCase(); + } + + // ----------------------- + // Response DTOs (서비스 안에 중첩으로 두면 파일명 문제 없음) + // ----------------------- + + public enum PublicShelfBookSort { LATEST, OLDEST, TITLE, AUTHOR } + + public record PublicShelfListResponse( + int totalCount, + List items + ) {} + + public record PublicShelfItem( + Long shelfId, + String name, + int bookCount, + List topBooks + ) {} + + public record PublicBookPreview( + Long bookId, + String thumbnailUrl, + String publisherName, + String authorName + ) {} + + public record PublicShelfBooksResponse( + int totalCount, + List items + ) {} + + public record PublicBookItem( + Long bookId, + String thumbnailUrl, + String publisherName, + String authorName + ) {} +} From f076af4f1dcfd58d06eec0ca6e51e24b6fc508cb Mon Sep 17 00:00:00 2001 From: ica Date: Wed, 4 Feb 2026 14:04:47 +0900 Subject: [PATCH 3/4] =?UTF-8?q?dto=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A0=80=EC=9E=90=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../UserPublicShelvesController.java | 13 +- .../dto/UserPublicShelfListResponse.java | 56 ++++---- .../service/UserPublicShelvesService.java | 121 ++++++++---------- 3 files changed, 86 insertions(+), 104 deletions(-) diff --git a/booklog/src/main/java/com/example/booklog/domain/users/controller/UserPublicShelvesController.java b/booklog/src/main/java/com/example/booklog/domain/users/controller/UserPublicShelvesController.java index e3f3652..c51e98e 100644 --- a/booklog/src/main/java/com/example/booklog/domain/users/controller/UserPublicShelvesController.java +++ b/booklog/src/main/java/com/example/booklog/domain/users/controller/UserPublicShelvesController.java @@ -1,9 +1,8 @@ package com.example.booklog.domain.users.controller; -import com.example.booklog.domain.library.shelves.service.UserPublicShelvesService; -import com.example.booklog.domain.library.shelves.service.UserPublicShelvesService.PublicShelfBookSort; -import com.example.booklog.domain.library.shelves.service.UserPublicShelvesService.PublicShelfBooksResponse; -import com.example.booklog.domain.library.shelves.service.UserPublicShelvesService.PublicShelfListResponse; +import com.example.booklog.domain.users.dto.UserPublicShelfListResponse; +import com.example.booklog.domain.users.service.UserPublicShelvesService; +import com.example.booklog.domain.users.service.UserPublicShelvesService.PublicShelfBookSort; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.*; import io.swagger.v3.oas.annotations.tags.Tag; @@ -12,7 +11,7 @@ @Tag( name = "다른 유저 공개 서재", - description = "다른 유저의 공개 서재 목록/서재 도서 목록 조회 API " + description = "다른 유저의 공개 서재 목록/서재 도서 목록 조회 API" ) @RestController @RequiredArgsConstructor @@ -34,7 +33,7 @@ public class UserPublicShelvesController { @ApiResponse(responseCode = "404", description = "유저 없음 또는 공개 서재 없음") }) @GetMapping - public PublicShelfListResponse listPublicShelves( + public UserPublicShelfListResponse listPublicShelves( @PathVariable Long userId ) { return userPublicShelvesService.listPublicShelves(userId); @@ -55,7 +54,7 @@ public PublicShelfListResponse listPublicShelves( @ApiResponse(responseCode = "404", description = "서재 없음 또는 비공개") }) @GetMapping("/{shelfId}/books") - public PublicShelfBooksResponse listPublicShelfBooks( + public UserPublicShelfListResponse.UserPublicShelfBooksResponse listPublicShelfBooks( @PathVariable Long userId, @PathVariable Long shelfId, @RequestParam(defaultValue = "LATEST") PublicShelfBookSort sort diff --git a/booklog/src/main/java/com/example/booklog/domain/users/dto/UserPublicShelfListResponse.java b/booklog/src/main/java/com/example/booklog/domain/users/dto/UserPublicShelfListResponse.java index 85b3ed1..fcf3ca7 100644 --- a/booklog/src/main/java/com/example/booklog/domain/users/dto/UserPublicShelfListResponse.java +++ b/booklog/src/main/java/com/example/booklog/domain/users/dto/UserPublicShelfListResponse.java @@ -6,34 +6,34 @@ public record UserPublicShelfListResponse( int totalCount, List items -) {} +) { + /** 서재 카드 1개 */ + public record UserPublicShelfItem( + Long shelfId, + String name, + int bookCount, + List topBooks + ) {} -/** 서재 카드 1개 */ -record UserPublicShelfItem( - Long shelfId, - String name, - int bookCount, - List topBooks -) {} + /** 서재 카드에 보여줄 책 프리뷰 1개 */ + public record ShelfBookPreview( + Long bookId, + String thumbnailUrl, + String publisherName, + String authorName + ) {} -/** 서재 카드에 보여줄 책 프리뷰 1개 */ -record ShelfBookPreview( - Long bookId, - String thumbnailUrl, - String publisherName, - String authorName -) {} + /** 특정 서재 도서 전체 목록 응답(페이징 없음) */ + public record UserPublicShelfBooksResponse( + int totalCount, + List items + ) {} -/** 특정 서재 도서 전체 목록 응답(페이징 없음) */ -record UserPublicShelfBooksResponse( - int totalCount, - List items -) {} - -/** 서재 내 도서 1개(상태 없음) */ -record UserPublicShelfBookItem( - Long bookId, - String thumbnailUrl, - String publisherName, - String authorName -) {} + /** 서재 내 도서 1개(상태 없음) */ + public record UserPublicShelfBookItem( + Long bookId, + String thumbnailUrl, + String publisherName, + String authorName + ) {} +} diff --git a/booklog/src/main/java/com/example/booklog/domain/users/service/UserPublicShelvesService.java b/booklog/src/main/java/com/example/booklog/domain/users/service/UserPublicShelvesService.java index 451639f..568d518 100644 --- a/booklog/src/main/java/com/example/booklog/domain/users/service/UserPublicShelvesService.java +++ b/booklog/src/main/java/com/example/booklog/domain/users/service/UserPublicShelvesService.java @@ -1,9 +1,12 @@ -package com.example.booklog.domain.library.shelves.service; +package com.example.booklog.domain.users.service; +import com.example.booklog.domain.library.books.entity.AuthorRole; +import com.example.booklog.domain.library.books.entity.BookAuthors; import com.example.booklog.domain.library.shelves.entity.BookshelfItems; import com.example.booklog.domain.library.shelves.entity.Bookshelves; import com.example.booklog.domain.library.shelves.repository.BookshelfItemsRepository; import com.example.booklog.domain.library.shelves.repository.BookshelvesRepository; +import com.example.booklog.domain.users.dto.UserPublicShelfListResponse; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,23 +22,26 @@ public class UserPublicShelvesService { private final BookshelvesRepository bookshelvesRepository; private final BookshelfItemsRepository bookshelfItemsRepository; + /** 정렬 enum(서비스에 남겨도 되고, dto 패키지로 빼도 됨) */ + public enum PublicShelfBookSort { LATEST, OLDEST, TITLE, AUTHOR } + /** 다른 유저 공개 서재 목록 + 서재별 top3 프리뷰 */ - public PublicShelfListResponse listPublicShelves(Long userId) { + public UserPublicShelfListResponse listPublicShelves(Long userId) { List shelves = bookshelvesRepository.findByUser_IdAndIsPublicTrueOrderByIdAsc(userId); - List items = shelves.stream() + List items = shelves.stream() .map(shelf -> { long bookCount = bookshelfItemsRepository.countByShelf_Id(shelf.getId()); // ✅ top3 프리뷰 - List top3 = bookshelfItemsRepository + List top3 = bookshelfItemsRepository .findTop3ByShelf_IdOrderByAddedAtDesc(shelf.getId()) .stream() .map(this::toPreview) .toList(); - return new PublicShelfItem( + return new UserPublicShelfListResponse.UserPublicShelfItem( shelf.getId(), shelf.getName(), (int) bookCount, @@ -44,16 +50,16 @@ public PublicShelfListResponse listPublicShelves(Long userId) { }) .toList(); - return new PublicShelfListResponse(items.size(), items); + return new UserPublicShelfListResponse(items.size(), items); } /** 특정 공개 서재의 전체 도서 목록(상태 없음, 정렬만) */ - public PublicShelfBooksResponse listPublicShelfBooks(Long userId, Long shelfId, PublicShelfBookSort sort) { - + public UserPublicShelfListResponse.UserPublicShelfBooksResponse listPublicShelfBooks( + Long userId, Long shelfId, PublicShelfBookSort sort + ) { // ✅ 공개 서재 검증 boolean ok = bookshelvesRepository.existsByIdAndUser_IdAndIsPublicTrue(shelfId, userId); if (!ok) { - // 너희 프로젝트 예외 코드로 바꿔도 됨 (SHELF_NOT_FOUND / PRIVATE 등) throw new IllegalArgumentException("SHELF_NOT_FOUND_OR_PRIVATE"); } @@ -74,20 +80,20 @@ public PublicShelfBooksResponse listPublicShelfBooks(Long userId, Long shelfId, .toList(); } - List items = rows.stream() + List items = rows.stream() .map(this::toItem) .toList(); - return new PublicShelfBooksResponse(items.size(), items); + return new UserPublicShelfListResponse.UserPublicShelfBooksResponse(items.size(), items); } // ----------------------- // mapping helpers // ----------------------- - private PublicBookPreview toPreview(BookshelfItems bi) { + private UserPublicShelfListResponse.ShelfBookPreview toPreview(BookshelfItems bi) { var b = bi.getBook(); - return new PublicBookPreview( + return new UserPublicShelfListResponse.ShelfBookPreview( b.getId(), b.getThumbnailUrl(), b.getPublisherName(), @@ -95,9 +101,9 @@ private PublicBookPreview toPreview(BookshelfItems bi) { ); } - private PublicBookItem toItem(BookshelfItems bi) { + private UserPublicShelfListResponse.UserPublicShelfBookItem toItem(BookshelfItems bi) { var b = bi.getBook(); - return new PublicBookItem( + return new UserPublicShelfListResponse.UserPublicShelfBookItem( b.getId(), b.getThumbnailUrl(), b.getPublisherName(), @@ -105,66 +111,43 @@ private PublicBookItem toItem(BookshelfItems bi) { ); } - /** - * ✅ 대표 저자명 가져오기 - * - 너희 엔티티 구조에 맞게 구현해줘야 함 - * - BookAuthors 같은 매핑 엔티티가 있으면 authorOrder=1 을 대표로 쓰는 형태가 일반적 - */ + /** ✅ 대표 저자명 가져오기: 엔티티 구조에 맞게 구현 */ private String getPrimaryAuthorName(BookshelfItems bi) { var book = bi.getBook(); + if (book == null) return null; + + var mappings = book.getBookAuthors(); + if (mappings == null || mappings.isEmpty()) return null; + + // 1) role=AUTHOR 중 대표 1명 + String author = mappings.stream() + .filter(m -> m.getRole() == AuthorRole.AUTHOR) + .sorted(Comparator.comparingInt(m -> safeOrder(m.getDisplayOrder()))) + .map(BookAuthors::getAuthor) + .map(a -> a != null ? a.getName() : null) + .filter(n -> n != null && !n.isBlank()) + .findFirst() + .orElse(null); + + if (author != null) return author; + + // 2) fallback: role이 이상하거나 누락된 데이터 대비 + return mappings.stream() + .sorted(Comparator.comparingInt(m -> safeOrder(m.getDisplayOrder()))) + .map(BookAuthors::getAuthor) + .map(a -> a != null ? a.getName() : null) + .filter(n -> n != null && !n.isBlank()) + .findFirst() + .orElse(null); + } - // TODO: 아래 중 너희 구조에 맞는 걸로 구현 - // 예시) book.getBookAuthors()가 있으면: - // return book.getBookAuthors().stream() - // .sorted(Comparator.comparingInt(BookAuthors::getAuthorOrder)) - // .map(ba -> ba.getAuthor().getName()) - // .findFirst().orElse(null); - - // 예시) book.getAuthors()가 List이면: - // return book.getAuthors().isEmpty() ? null : book.getAuthors().get(0); - - return null; + private int safeOrder(Integer order) { + return order == null ? Integer.MAX_VALUE : order; } + private String normalize(String s) { - if (s == null) return "\uFFFF"; // null은 뒤로 보내기 + if (s == null) return "\uFFFF"; return s.trim().toLowerCase(); } - - // ----------------------- - // Response DTOs (서비스 안에 중첩으로 두면 파일명 문제 없음) - // ----------------------- - - public enum PublicShelfBookSort { LATEST, OLDEST, TITLE, AUTHOR } - - public record PublicShelfListResponse( - int totalCount, - List items - ) {} - - public record PublicShelfItem( - Long shelfId, - String name, - int bookCount, - List topBooks - ) {} - - public record PublicBookPreview( - Long bookId, - String thumbnailUrl, - String publisherName, - String authorName - ) {} - - public record PublicShelfBooksResponse( - int totalCount, - List items - ) {} - - public record PublicBookItem( - Long bookId, - String thumbnailUrl, - String publisherName, - String authorName - ) {} } From 5415860f22703e6ef43b3796c7194f592a686e1f Mon Sep 17 00:00:00 2001 From: ica Date: Wed, 4 Feb 2026 14:22:46 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EB=93=B1=EB=8B=B5=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC=20=EB=A6=AC=ED=8E=99=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/users/service/UserPublicShelvesService.java | 4 +++- .../global/common/apiPayload/code/status/ErrorStatus.java | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/booklog/src/main/java/com/example/booklog/domain/users/service/UserPublicShelvesService.java b/booklog/src/main/java/com/example/booklog/domain/users/service/UserPublicShelvesService.java index 568d518..f2a02ab 100644 --- a/booklog/src/main/java/com/example/booklog/domain/users/service/UserPublicShelvesService.java +++ b/booklog/src/main/java/com/example/booklog/domain/users/service/UserPublicShelvesService.java @@ -7,6 +7,8 @@ import com.example.booklog.domain.library.shelves.repository.BookshelfItemsRepository; import com.example.booklog.domain.library.shelves.repository.BookshelvesRepository; import com.example.booklog.domain.users.dto.UserPublicShelfListResponse; +import com.example.booklog.global.common.apiPayload.code.status.ErrorStatus; +import com.example.booklog.global.common.apiPayload.exception.GeneralException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -60,7 +62,7 @@ public UserPublicShelfListResponse.UserPublicShelfBooksResponse listPublicShelfB // ✅ 공개 서재 검증 boolean ok = bookshelvesRepository.existsByIdAndUser_IdAndIsPublicTrue(shelfId, userId); if (!ok) { - throw new IllegalArgumentException("SHELF_NOT_FOUND_OR_PRIVATE"); + throw new GeneralException(ErrorStatus.SHELF_NOT_FOUND"SHELF_NOT_FOUND_OR_PRIVATE"); } List rows = switch (sort) { diff --git a/booklog/src/main/java/com/example/booklog/global/common/apiPayload/code/status/ErrorStatus.java b/booklog/src/main/java/com/example/booklog/global/common/apiPayload/code/status/ErrorStatus.java index 9fae758..8607230 100644 --- a/booklog/src/main/java/com/example/booklog/global/common/apiPayload/code/status/ErrorStatus.java +++ b/booklog/src/main/java/com/example/booklog/global/common/apiPayload/code/status/ErrorStatus.java @@ -61,6 +61,7 @@ public enum ErrorStatus implements BaseErrorCode { SHELF_NOT_FOUND_OR_NOT_OWNED(HttpStatus.NOT_FOUND, "S002", "서재 없음 또는 내 서재 아님"), SHELF_NOT_OWNED(HttpStatus.NOT_FOUND, "S003", "내 서재 아님"), DUPLICATE_SHELF_NAME(HttpStatus.NOT_FOUND, "S004", "중복된 서재 이름"), + SHELF_NOT_FOUND_OR_PRIVATE(HttpStatus.NOT_FOUND, "S005", "비공개인 서재 또는 서재 없음"), // ========================= // [UserBooks / Library]