Skip to content
Merged
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
141 changes: 135 additions & 6 deletions src/main/java/com/dokdok/book/api/BookApi.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package com.dokdok.book.api;

import com.dokdok.book.dto.request.BookCreateRequest;
import com.dokdok.book.dto.request.BookBulkDeleteRequest;
import com.dokdok.book.dto.request.PersonalBookSortBy;
import com.dokdok.book.dto.request.PersonalBookSortOrder;
import com.dokdok.book.dto.response.*;
import com.dokdok.book.entity.BookReadingStatus;
import com.dokdok.global.response.ApiResponse;
Expand All @@ -14,13 +17,13 @@
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.repository.query.Param;
import org.springframework.data.web.PageableDefault;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;
import java.time.OffsetDateTime;

@Tag(name = "책 관리", description = "책 검색 및 내 책장 관리 API")
Expand Down Expand Up @@ -214,16 +217,18 @@ ResponseEntity<ApiResponse<CursorPageResponse<KakaoBookResponse.Document, BookSe
description = """
내 책장에 등록된 책을 커서 기반으로 조회합니다.
- 로그인한 사용자 기준으로 조회합니다.
- cursorAddedAt/cursorBookId/size 파라미터로 다음 페이지를 조회합니다.
- 독서 상태 필터 (ENUM: READING/COMPLETED/PENDING)
- 정렬 파라미터: sortBy(TIME|RATING), sortOrder(DESC|ASC)
- 커서 파라미터: cursorRating/cursorAddedAt/cursorBookId
- 책이 없는 경우에도 200 응답이며 items는 빈 배열입니다.
"""
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "책 리스트 조회 성공",
content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = PersonalBookListResponse.class),
schema = @Schema(implementation = PersonalBookCursorPageResponse.class),
examples = @io.swagger.v3.oas.annotations.media.ExampleObject(
value = """
{
Expand All @@ -238,12 +243,25 @@ ResponseEntity<ApiResponse<CursorPageResponse<KakaoBookResponse.Document, BookSe
"authors": "저자A, 저자B",
"bookReadingStatus": "READING",
"thumbnail": "https://example.com/thumb.jpg",
"gatheringName": "예제 모임"
"rating": 4.5,
"gatherings": [
{
"gatheringId": 10,
"gatheringName": "예제 모임"
}
]
}
],
"statusCounts": {
"reading": 12,
"completed": 7,
"pending": 3,
"total": 22
},
"pageSize": 10,
"hasNext": true,
"nextCursor": {
"rating": 4.5,
"addedAt": "2026-01-22T10:25:40Z",
"bookId": 127
},
Expand Down Expand Up @@ -306,9 +324,15 @@ ResponseEntity<ApiResponse<CursorPageResponse<KakaoBookResponse.Document, BookSe
)
})
@GetMapping
ResponseEntity<ApiResponse<CursorPageResponse<PersonalBookListResponse, BookListCursor>>> getMyBooks(
ResponseEntity<ApiResponse<PersonalBookCursorPageResponse>> getMyBooks(
@RequestParam(required = false) BookReadingStatus readingStatus,
@RequestParam(required = false) Long gatheringId,
@Parameter(description = "정렬 기준 (TIME | RATING)", example = "TIME")
@RequestParam(required = false) PersonalBookSortBy sortBy,
@Parameter(description = "정렬 방향 (DESC | ASC)", example = "DESC")
@RequestParam(required = false) PersonalBookSortOrder sortOrder,
@Parameter(description = "커서 - 마지막 아이템 rating (RATING 정렬 시 사용, null 가능)", example = "4.5")
@RequestParam(required = false) BigDecimal cursorRating,
@Parameter(
description = "커서 - 마지막 아이템 addedAt (ISO 8601, cursorBookId와 함께 전달)"
)
Expand Down Expand Up @@ -525,6 +549,105 @@ ResponseEntity<ApiResponse<Void>> deleteMyBook(
@PathVariable Long bookId
);

@Operation(
summary = "내 책장에서 책 일괄 삭제 (developer: 권우희)",
description = """
내 책장에 등록된 책 여러 권을 한 번에 삭제합니다.
- 로그인한 사용자 소유의 책만 삭제할 수 있습니다.
- 요청 본문의 bookIds 배열에 삭제할 book ID 목록을 전달합니다.
"""
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "책 일괄 삭제 성공",
content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = Void.class),
examples = @io.swagger.v3.oas.annotations.media.ExampleObject(
value = """
{
"code": "DELETED",
"message": "책 일괄 삭제 성공",
"data": null
}
"""
))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "400",
description = "잘못된 요청",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ApiResponse.class),
examples = @io.swagger.v3.oas.annotations.media.ExampleObject(
value = """
{
"code": "G002",
"message": "입력값이 올바르지 않습니다.",
"data": null
}
"""
)
)
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "401",
description = "인증 실패 - 로그인이 필요합니다.",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ApiResponse.class),
examples = @io.swagger.v3.oas.annotations.media.ExampleObject(
value = """
{
"code": "G102",
"message": "인증이 필요합니다.",
"data": null
}
"""
)
)
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "책을 찾을 수 없음",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ApiResponse.class),
examples = @io.swagger.v3.oas.annotations.media.ExampleObject(
value = """
{
"code": "B003",
"message": "책장에 해당 책이 존재하지 않습니다.",
"data": null
}
"""
)
)
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "500",
description = "서버 오류",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ApiResponse.class),
examples = @io.swagger.v3.oas.annotations.media.ExampleObject(
value = """
{
"code": "E000",
"message": "서버 에러가 발생했습니다. 담당자에게 문의 바랍니다.",
"data": null
}
"""
)
)
)
})
@DeleteMapping
ResponseEntity<ApiResponse<Void>> deleteMyBooks(
@Parameter(description = "일괄 삭제할 책 ID 목록", required = true)
@Valid @RequestBody BookBulkDeleteRequest request
);

@Operation(
summary = "읽고 있는 책 목록 조회 (developer: 권우희)",
description = """
Expand Down Expand Up @@ -553,7 +676,13 @@ ResponseEntity<ApiResponse<Void>> deleteMyBook(
"authors": "저자A, 저자B",
"bookReadingStatus": "READING",
"thumbnail": "https://example.com/thumb.jpg",
"gatheringName": "예제 모임"
"rating": 4.0,
"gatherings": [
{
"gatheringId": 10,
"gatheringName": "예제 모임"
}
]
}
],
"totalCount": 1,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,7 @@ ResponseEntity<ApiResponse<PersonalReadingTopicAnswerResponse>> getMyTopicAnswer
독서 기록/사전 의견/개인 회고를 하나의 타임라인으로 커서 기반 조회합니다.
- personalBook의 gatheringId가 null이면 사전 의견/회고는 제외됩니다.
- 사전 의견(PRE_OPINION)은 **내 답변이 있는 미팅만** 포함합니다.
- PRE_OPINION의 preOpinion 객체에는 gatheringId/meetingId가 포함됩니다.
- 정렬: eventAt DESC, typeOrder DESC, sourceId DESC
- preOpinionTime: 사전 의견 정렬 기준 (MEETING_START | ANSWER_CREATED, 기본값 ANSWER_CREATED)

Expand Down
28 changes: 25 additions & 3 deletions src/main/java/com/dokdok/book/controller/BookController.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.dokdok.book.controller;

import com.dokdok.book.api.BookApi;
import com.dokdok.book.dto.request.BookBulkDeleteRequest;
import com.dokdok.book.dto.request.BookCreateRequest;
import com.dokdok.book.dto.request.PersonalBookSortBy;
import com.dokdok.book.dto.request.PersonalBookSortOrder;
import com.dokdok.book.dto.response.*;
import com.dokdok.book.entity.BookReadingStatus;
import com.dokdok.book.service.BookService;
Expand All @@ -15,6 +18,7 @@
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;
import java.time.OffsetDateTime;

@RestController
Expand Down Expand Up @@ -44,17 +48,28 @@ public ResponseEntity<ApiResponse<PersonalBookCreateResponse>> createBook(@Valid

@Override
@GetMapping
public ResponseEntity<ApiResponse<CursorPageResponse<PersonalBookListResponse, BookListCursor>>> getMyBooks(
public ResponseEntity<ApiResponse<PersonalBookCursorPageResponse>> getMyBooks(
BookReadingStatus readingStatus,
Long gatheringId,
PersonalBookSortBy sortBy,
PersonalBookSortOrder sortOrder,
@RequestParam(required = false) BigDecimal cursorRating,
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
OffsetDateTime cursorAddedAt,
@RequestParam(required = false) Long cursorBookId,
@RequestParam(required = false) Integer size
) {
CursorPageResponse<PersonalBookListResponse, BookListCursor> response = personalBookService
.getPersonalBookListCursor(readingStatus, gatheringId, cursorAddedAt, cursorBookId, size);
PersonalBookCursorPageResponse response = personalBookService
.getPersonalBookListCursor(
readingStatus,
gatheringId,
sortBy,
sortOrder,
cursorAddedAt,
cursorBookId,
size
);
return ApiResponse.success(response, "책 리스트 조회 성공");
}

Expand All @@ -72,6 +87,13 @@ public ResponseEntity<ApiResponse<Void>> deleteMyBook(@PathVariable Long bookId)
return ApiResponse.deleted("책 삭제 성공");
}

@Override
@DeleteMapping
public ResponseEntity<ApiResponse<Void>> deleteMyBooks(@Valid @RequestBody BookBulkDeleteRequest request) {
personalBookService.deleteBooks(request.bookIds());
return ApiResponse.deleted("책 일괄 삭제 성공");
}


@Override
@GetMapping("/reading")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.dokdok.book.dto.request;

import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;

import java.util.List;

public record BookBulkDeleteRequest(
@NotEmpty(message = "bookIds는 필수입니다.")
List<
@NotNull(message = "bookIds의 각 값은 필수입니다.")
@Positive(message = "bookIds의 각 값은 양수여야 합니다.")
Long> bookIds
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.dokdok.book.dto.request;

public enum PersonalBookSortBy {
TIME,
RATING
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.dokdok.book.dto.request;

public enum PersonalBookSortOrder {
DESC,
ASC
}
11 changes: 9 additions & 2 deletions src/main/java/com/dokdok/book/dto/response/BookListCursor.java
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
package com.dokdok.book.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;

public record BookListCursor(
@Schema(description = "마지막 아이템의 별점 (RATING 정렬 시 사용)", example = "4.5", nullable = true)
BigDecimal rating,
@Schema(description = "마지막 아이템의 등록 시간", example = "2026-01-22T10:25:40Z")
OffsetDateTime addedAt,
@Schema(description = "마지막 아이템의 bookId", example = "127")
Long bookId
) {
public static BookListCursor from(LocalDateTime addedAt, Long bookId) {
public static BookListCursor from(BigDecimal rating, LocalDateTime addedAt, Long bookId) {
if (addedAt == null || bookId == null) {
return null;
}
return new BookListCursor(OffsetDateTime.of(addedAt, ZoneOffset.UTC), bookId);
return new BookListCursor(rating, OffsetDateTime.of(addedAt, ZoneOffset.UTC), bookId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package com.dokdok.book.dto.response;

import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Getter;

import java.util.List;

@Getter
@AllArgsConstructor
@JsonPropertyOrder({"items", "statusCounts", "pageSize", "hasNext", "nextCursor", "totalCount"})
public class PersonalBookCursorPageResponse {

@Schema(description = "아이템 목록")
private List<PersonalBookListResponse> items;

@Schema(description = "상태별 개수")
private PersonalBookStatusCountsResponse statusCounts;

@Schema(hidden = true)
private int pageSize;

@Schema(hidden = true)
private boolean hasNext;

@Schema(hidden = true)
private BookListCursor nextCursor;

@Schema(description = "현재 필터 기준 전체 아이템 수")
private long totalCount;

public static PersonalBookCursorPageResponse of(
List<PersonalBookListResponse> items,
PersonalBookStatusCountsResponse statusCounts,
int pageSize,
boolean hasNext,
BookListCursor nextCursor,
long totalCount
) {
return new PersonalBookCursorPageResponse(items, statusCounts, pageSize, hasNext, nextCursor, totalCount);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.dokdok.book.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;

@Schema(description = "책과 연결된 모임 정보")
public record PersonalBookGatheringResponse(
@Schema(description = "모임 ID", example = "10")
Long gatheringId,

@Schema(description = "모임 이름", example = "예제 모임")
String gatheringName
) {
}
Loading