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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public interface BookshelfItemsRepository extends JpaRepository<BookshelfItems,
@Query("select bi.book.id from BookshelfItems bi where bi.shelf.id = :shelfId")
List<Long> findBookIdsByShelfId(@Param("shelfId") Long shelfId);

/** (선택) 특정 서재에 담긴 BookshelfItems 전체 */
/** 특정 서재에 담긴 BookshelfItems 전체 */
@Query("select bi from BookshelfItems bi where bi.shelf.id = :shelfId")
List<BookshelfItems> findAllByShelfId(@Param("shelfId") Long shelfId);

Expand All @@ -34,7 +34,7 @@ public interface BookshelfItemsRepository extends JpaRepository<BookshelfItems,
@Query("delete from BookshelfItems bi where bi.shelf.id = :shelfId and bi.book.id = :bookId")
int deleteByShelfIdAndBookId(@Param("shelfId") Long shelfId, @Param("bookId") Long bookId);

/** ✅ (추가) 특정 서재에서 선택한 여러 권 제거 */
/** 특정 서재에서 선택한 여러 권 제거 */
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("delete from BookshelfItems bi where bi.shelf.id = :shelfId and bi.book.id in :bookIds")
int deleteByShelfIdAndBookIds(@Param("shelfId") Long shelfId, @Param("bookIds") List<Long> bookIds);
Expand All @@ -44,18 +44,31 @@ public interface BookshelfItemsRepository extends JpaRepository<BookshelfItems,
@Query("delete from BookshelfItems bi where bi.book.id in :bookIds")
int deleteByBookIds(@Param("bookIds") List<Long> 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<BookshelfItems> findByShelf_IdOrderByAddedAtDesc(Long shelfId);
List<BookshelfItems> findTop3ByShelf_IdOrderByAddedAtDesc(Long shelfId);

/** ✅ (추가) 서재 카드에 "n권" 표시용: 해당 서재의 전체 도서 수 */
long countByShelf_Id(Long shelfId);

/** ✅ 서재 비우기(UNASSIGN 정책에서 사용) */
int deleteByShelf_Id(Long shelfId);
/** ✅ (추가) 전체보기 정렬(최신순)용: 서재의 전체 도서 목록을 최근 담은 순으로 조회 */
@EntityGraph(attributePaths = "book")
List<BookshelfItems> findByShelf_IdOrderByAddedAtDesc(Long shelfId);

/** ✅ (추가) 전체보기 정렬(오래된순)용: 서재의 전체 도서 목록을 오래된 순으로 조회 */
@EntityGraph(attributePaths = "book")
List<BookshelfItems> findByShelf_IdOrderByAddedAtAsc(Long shelfId);

/** ✅ (추가) 전체보기 정렬(제목순)용: 서재의 전체 도서 목록을 제목 오름차순으로 조회 */
@EntityGraph(attributePaths = "book")
List<BookshelfItems> findByShelf_IdOrderByBook_TitleAsc(Long shelfId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,10 @@ public interface BookshelvesRepository extends JpaRepository<Bookshelves, Long>
Optional<Bookshelves> findByIdAndUser_Id(Long shelfId, Long userId);

boolean existsByUser_IdAndName(Long userId, String name);

/** ✅ (추가) 다른 유저 공개 서재 목록 조회 */
List<Bookshelves> findByUser_IdAndIsPublicTrueOrderByIdAsc(Long userId);

/** ✅ (추가) 다른 유저 공개 서재 접근 검증(소유 + 공개) */
boolean existsByIdAndUser_IdAndIsPublicTrue(Long shelfId, Long userId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.example.booklog.domain.users.controller;

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;
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 UserPublicShelfListResponse 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 UserPublicShelfListResponse.UserPublicShelfBooksResponse listPublicShelfBooks(
@PathVariable Long userId,
@PathVariable Long shelfId,
@RequestParam(defaultValue = "LATEST") PublicShelfBookSort sort
) {
return userPublicShelvesService.listPublicShelfBooks(userId, shelfId, sort);
}
}
Original file line number Diff line number Diff line change
@@ -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<UserProfileResponse> 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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.example.booklog.domain.users.dto;

public record UserProfileResponse(
Long userId,
String nickname,
String email,
String avatarUrl,
Comment on lines +3 to +7
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do not expose email in other-user profiles.

The PR targets public/other-user profile viewing; returning email is a PII leak and a compliance/privacy risk. Remove it from the public response or gate it to “self only.”

🔐 Proposed fix (public response)
 public record UserProfileResponse(
         Long userId,
         String nickname,
-        String email,
         String avatarUrl,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public record UserProfileResponse(
Long userId,
String nickname,
String email,
String avatarUrl,
public record UserProfileResponse(
Long userId,
String nickname,
String avatarUrl,
🤖 Prompt for AI Agents
In
`@booklog/src/main/java/com/example/booklog/domain/users/dto/UserProfileResponse.java`
around lines 3 - 7, The UserProfileResponse record currently exposes email (PII)
and must not be returned for other-user public profile views; remove the email
field from the public record UserProfileResponse and update any builders/mappers
that construct UserProfileResponse (e.g., UserMapper.toUserProfileResponse or
similar factory methods) to stop populating or expecting email; if you still
need to return email for the account owner, introduce a separate record/type
(e.g., SelfUserProfileResponse) or add an explicit method that returns a
self-only response including email and update controller/service endpoints so
public profile endpoints use the sanitized UserProfileResponse while
self-profile endpoints use the self-only type.


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
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.example.booklog.domain.users.dto;

import java.util.List;

/** 공개 서재 목록 응답(대표 3권 포함) */
public record UserPublicShelfListResponse(
int totalCount,
List<UserPublicShelfItem> items
) {
/** 서재 카드 1개 */
public record UserPublicShelfItem(
Long shelfId,
String name,
int bookCount,
List<ShelfBookPreview> topBooks
) {}

/** 서재 카드에 보여줄 책 프리뷰 1개 */
public record ShelfBookPreview(
Long bookId,
String thumbnailUrl,
String publisherName,
String authorName
) {}

/** 특정 서재 도서 전체 목록 응답(페이징 없음) */
public record UserPublicShelfBooksResponse(
int totalCount,
List<UserPublicShelfBookItem> items
) {}

/** 서재 내 도서 1개(상태 없음) */
public record UserPublicShelfBookItem(
Long bookId,
String thumbnailUrl,
String publisherName,
String authorName
) {}
}
Original file line number Diff line number Diff line change
@@ -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<Users, Long> {

@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<UserProfileSummaryProjection> findUserProfileSummary(
@Param("meId") Long meId,
@Param("targetUserId") Long targetUserId
);
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading