diff --git a/booklog/src/main/java/com/example/booklog/aws/s3/AmazonS3Manager.java b/booklog/src/main/java/com/example/booklog/aws/s3/AmazonS3Manager.java index f331628..bba0184 100644 --- a/booklog/src/main/java/com/example/booklog/aws/s3/AmazonS3Manager.java +++ b/booklog/src/main/java/com/example/booklog/aws/s3/AmazonS3Manager.java @@ -46,4 +46,6 @@ public String generateReviewKeyName(Uuid uuid) { public String generateProfileKeyName(Uuid uuid) { return amazonConfig.getProfilePath() + '/' + uuid.getUuid(); } + + } diff --git a/booklog/src/main/java/com/example/booklog/domain/library/shelves/controller/BookshelvesController.java b/booklog/src/main/java/com/example/booklog/domain/library/shelves/controller/BookshelvesController.java index d2b7fc2..e0efbf1 100644 --- a/booklog/src/main/java/com/example/booklog/domain/library/shelves/controller/BookshelvesController.java +++ b/booklog/src/main/java/com/example/booklog/domain/library/shelves/controller/BookshelvesController.java @@ -1,14 +1,18 @@ +// ===================================== +// [서재] BookshelvesController +// ===================================== package com.example.booklog.domain.library.shelves.controller; import com.example.booklog.domain.library.shelves.dto.*; import com.example.booklog.domain.library.shelves.service.BookshelvesService; +import com.example.booklog.global.auth.security.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.*; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; @@ -25,7 +29,7 @@ public class BookshelvesController { summary = "서재 생성", description = """ 새로운 서재를 생성합니다. - - 헤더: X-USER-ID (필수) + - 인증: Access Token(Bearer) - Body: name(필수), isPublic(선택), sortOrder(선택) - 응답: shelfId """ @@ -33,44 +37,42 @@ public class BookshelvesController { @ApiResponses({ @ApiResponse(responseCode = "200", description = "성공"), @ApiResponse(responseCode = "400", description = "요청 값 검증 실패"), - @ApiResponse(responseCode = "401", description = "유저 식별 실패"), + @ApiResponse(responseCode = "401", description = "인증 실패(토큰 누락/만료/위조)"), @ApiResponse(responseCode = "409", description = "중복 서재명") }) @PostMapping public BookshelfCreateResponse create( - @Parameter(name = "X-USER-ID", description = "유저 식별자", required = true, example = "1") - @RequestHeader(name = "X-USER-ID") Long userId, + @AuthenticationPrincipal CustomUserDetails userDetails, @RequestBody @Valid BookshelfCreateRequest req ) { - return bookshelvesService.create(userId, req); + return bookshelvesService.create(userDetails.getUserId(), req); } @Operation( summary = "서재 목록 조회(프리뷰 3권 포함)", description = """ 내 서재 목록을 조회합니다. - - 헤더: X-USER-ID (필수) + - 인증: Access Token(Bearer) - 응답: 서재 리스트 + 각 서재별 previewBooks(최대 3권) - - previewBooks: 썸네일, 제목, 저자명, 출판사 + - previewBooks: 썸네일, 제목, 저자명, 출판사(리스트 UI용) """ ) @ApiResponses({ @ApiResponse(responseCode = "200", description = "성공"), - @ApiResponse(responseCode = "401", description = "유저 식별 실패") + @ApiResponse(responseCode = "401", description = "인증 실패") }) @GetMapping public List list( - @Parameter(name = "X-USER-ID", description = "유저 식별자", required = true, example = "1") - @RequestHeader(name = "X-USER-ID") Long userId + @AuthenticationPrincipal CustomUserDetails userDetails ) { - return bookshelvesService.list(userId); + return bookshelvesService.list(userDetails.getUserId()); } @Operation( summary = "서재 수정(PATCH)", description = """ 서재의 일부 필드를 변경합니다. - - 헤더: X-USER-ID (필수) + - 인증: Access Token(Bearer) - Path: shelfId - Body: name/isPublic/sortOrder 중 변경할 값만 전달 - 응답: 204 No Content @@ -78,48 +80,44 @@ public List list( ) @ApiResponses({ @ApiResponse(responseCode = "204", description = "성공"), - @ApiResponse(responseCode = "401", description = "유저 식별 실패"), - @ApiResponse(responseCode = "404", description = "서재 없음 또는 내 서재 아님") + @ApiResponse(responseCode = "400", description = "요청 값 검증 실패"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "서재 없음 또는 내 서재 아님"), + @ApiResponse(responseCode = "409", description = "중복 서재명") }) @PatchMapping("/{shelfId}") @ResponseStatus(HttpStatus.NO_CONTENT) public void update( - @Parameter(name = "X-USER-ID", description = "유저 식별자", required = true, example = "1") - @RequestHeader(name = "X-USER-ID") Long userId, - - @Parameter(name = "shelfId", description = "서재 ID", required = true, example = "10") + @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable(name = "shelfId") Long shelfId, - @RequestBody BookshelfUpdateRequest req ) { - bookshelvesService.update(userId, shelfId, req); + bookshelvesService.update(userDetails.getUserId(), shelfId, req); } @Operation( summary = "서재 삭제(UNASSIGN 고정)", description = """ 서재를 삭제합니다. - - 헤더: X-USER-ID (필수) + - 인증: Access Token(Bearer) - Path: shelfId - - 정책: bookshelf_items(서재-책 매핑)만 제거 후 서재 삭제 - user_books(라이브러리)는 유지 + - 정책(UNASSIGN): + - bookshelf_items(서재-책 매핑)만 제거 후 서재 삭제 + - user_books(라이브러리)는 유지 - 응답: 204 No Content """ ) @ApiResponses({ @ApiResponse(responseCode = "204", description = "성공"), - @ApiResponse(responseCode = "401", description = "유저 식별 실패"), + @ApiResponse(responseCode = "401", description = "인증 실패"), @ApiResponse(responseCode = "404", description = "서재 없음 또는 내 서재 아님") }) @DeleteMapping("/{shelfId}") @ResponseStatus(HttpStatus.NO_CONTENT) public void delete( - @Parameter(name = "X-USER-ID", description = "유저 식별자", required = true, example = "1") - @RequestHeader(name = "X-USER-ID") Long userId, - - @Parameter(name = "shelfId", description = "서재 ID", required = true, example = "10") + @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable(name = "shelfId") Long shelfId ) { - bookshelvesService.delete(userId, shelfId); + bookshelvesService.delete(userDetails.getUserId(), shelfId); } } diff --git a/booklog/src/main/java/com/example/booklog/domain/library/shelves/controller/ReadingLogsController.java b/booklog/src/main/java/com/example/booklog/domain/library/shelves/controller/ReadingLogsController.java index 4e566be..f7100b6 100644 --- a/booklog/src/main/java/com/example/booklog/domain/library/shelves/controller/ReadingLogsController.java +++ b/booklog/src/main/java/com/example/booklog/domain/library/shelves/controller/ReadingLogsController.java @@ -1,22 +1,24 @@ +// ===================================== +// [독서기록] ReadingLogsController +// ===================================== package com.example.booklog.domain.library.shelves.controller; import com.example.booklog.domain.library.shelves.dto.ReadingLogResponse; import com.example.booklog.domain.library.shelves.dto.ReadingLogSaveRequest; import com.example.booklog.domain.library.shelves.service.ReadingLogsService; +import com.example.booklog.global.auth.security.CustomUserDetails; 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.ExampleObject; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.media.*; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import io.swagger.v3.oas.annotations.Parameter.*; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "독서 기록", description = "독서 기록(날짜/읽은 페이지) 저장·수정·삭제 API") +@Tag(name = "독서 기록(Reading Logs)", description = "독서 기록 저장·수정·삭제 API") @RestController @RequiredArgsConstructor public class ReadingLogsController { @@ -24,111 +26,83 @@ public class ReadingLogsController { private final ReadingLogsService readingLogsService; @Operation( - summary = "독서 기록 저장", + summary = "독서 기록 저장(append)", description = """ 특정 저장 도서(userBookId)에 대해 독서 기록을 추가(append)합니다. - - UI 입력: readDate(읽은 날짜), pagesRead(그날 읽은 페이지 수) - - 서버 처리: currentPage(누적 현재 페이지)는 서버가 계산하여 저장하며, - user_books.current_page/progress_percent 등의 최신 상태도 함께 갱신됩니다. + - 인증: Access Token(Bearer) + - 입력: readDate(날짜), pagesRead(그날 읽은 페이지 수) + - 처리: 누적 currentPage는 서버 계산, user_books 최신상태 함께 갱신 """ ) @ApiResponse(responseCode = "200", description = "저장 성공", content = @Content(schema = @Schema(implementation = ReadingLogResponse.class))) @ApiResponse(responseCode = "400", description = "요청값 오류/저장 도서 없음", content = @Content) + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content) @PostMapping(value = "/api/v1/user-books/{userBookId}/reading-logs", consumes = MediaType.APPLICATION_JSON_VALUE) public ReadingLogResponse create( - @Parameter(name = "X-USER-ID", description = "유저 식별자(필수)", required = true, example = "1") - @RequestHeader("X-USER-ID") Long userId, - - @Parameter(description = "저장 도서 ID(user_books.user_book_id)", required = true, example = "101") + @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long userBookId, - - @RequestBody( + //스웨거(OpenAPI 문서용) RequestBody + @io.swagger.v3.oas.annotations.parameters.RequestBody( required = true, description = "독서 기록 저장 요청", content = @Content( schema = @Schema(implementation = ReadingLogSaveRequest.class), - examples = { - @ExampleObject( - name = "기본 예시", - summary = "특정 날짜에 57페이지 읽음", - value = """ - { - "readDate": "2026-01-10", - "pagesRead": 57 - } - """ - ) - } + examples = @ExampleObject( + name = "기본 예시", + summary = "특정 날짜에 57페이지 읽음", + value = """ + { + "readDate": "2026-01-10", + "pagesRead": 57 + } + """ + ) ) ) @org.springframework.web.bind.annotation.RequestBody @Valid ReadingLogSaveRequest req ) { - return readingLogsService.create(userId, userBookId, req); + return readingLogsService.create(userDetails.getUserId(), userBookId, req); } @Operation( summary = "독서 기록 수정", description = """ 특정 독서 기록(logId)의 날짜/읽은 페이지를 수정합니다. - - UI 입력: readDate, pagesRead - - 서버 처리: 중간 기록 수정 시 누적 currentPage가 연쇄 변경될 수 있어, - 해당 userBook의 로그들을 기준으로 currentPage/user_books 상태를 재계산합니다. + - 인증: Access Token(Bearer) + - 처리: 중간 기록 수정 시 누적값이 연쇄 변경될 수 있어 user_books 상태 재계산 """ ) @ApiResponse(responseCode = "200", description = "수정 성공", content = @Content(schema = @Schema(implementation = ReadingLogResponse.class))) @ApiResponse(responseCode = "404", description = "독서 기록 없음/권한 없음", content = @Content) + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content) @PatchMapping(value = "/api/v1/reading-logs/{logId}", consumes = MediaType.APPLICATION_JSON_VALUE) public ReadingLogResponse update( - @Parameter(name = "X-USER-ID", description = "유저 식별자(필수)", required = true, example = "1") - @RequestHeader("X-USER-ID") Long userId, - - @Parameter(description = "독서 기록 ID(reading_logs.log_id)", required = true, example = "5001") + @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long logId, - - @RequestBody( - required = true, - description = "독서 기록 수정 요청", - content = @Content( - schema = @Schema(implementation = ReadingLogSaveRequest.class), - examples = { - @ExampleObject( - name = "수정 예시", - summary = "페이지 수를 80으로 수정", - value = """ - { - "readDate": "2026-01-10", - "pagesRead": 80 - } - """ - ) - } - ) - ) @org.springframework.web.bind.annotation.RequestBody @Valid ReadingLogSaveRequest req ) { - return readingLogsService.update(userId, logId, req); + return readingLogsService.update(userDetails.getUserId(), logId, req); } @Operation( summary = "독서 기록 삭제", description = """ 특정 독서 기록(logId)을 삭제합니다. - - 서버 처리: 삭제 후 해당 userBook의 로그 기반으로 user_books 최신상태를 재계산합니다. + - 인증: Access Token(Bearer) + - 처리: 삭제 후 해당 userBook 로그 기반으로 user_books 최신상태 재계산 """ ) @ApiResponse(responseCode = "204", description = "삭제 성공", content = @Content) @ApiResponse(responseCode = "404", description = "독서 기록 없음/권한 없음", content = @Content) + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content) @DeleteMapping("/api/v1/reading-logs/{logId}") @ResponseStatus(org.springframework.http.HttpStatus.NO_CONTENT) public void delete( - @Parameter(name = "X-USER-ID", description = "유저 식별자(필수)", required = true, example = "1") - @RequestHeader("X-USER-ID") Long userId, - - @Parameter(description = "독서 기록 ID(reading_logs.log_id)", required = true, example = "5001") + @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable Long logId ) { - readingLogsService.delete(userId, logId); + readingLogsService.delete(userDetails.getUserId(), logId); } } diff --git a/booklog/src/main/java/com/example/booklog/domain/library/shelves/controller/UserBooksController.java b/booklog/src/main/java/com/example/booklog/domain/library/shelves/controller/UserBooksController.java index 446521c..aedc8d4 100644 --- a/booklog/src/main/java/com/example/booklog/domain/library/shelves/controller/UserBooksController.java +++ b/booklog/src/main/java/com/example/booklog/domain/library/shelves/controller/UserBooksController.java @@ -1,28 +1,31 @@ +// ===================================== +// [도서] UserBooksController +// ===================================== package com.example.booklog.domain.library.shelves.controller; import com.example.booklog.domain.library.shelves.dto.*; import com.example.booklog.domain.library.shelves.entity.ReadingStatus; import com.example.booklog.domain.library.shelves.entity.UserBookSort; import com.example.booklog.domain.library.shelves.service.UserBooksService; +import com.example.booklog.global.auth.security.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.responses.*; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import java.util.List; @Tag( - name = "저장 도서(UserBooks)", - description = "유저의 저장 도서(user_books) 및 서재 매핑(bookshelf_items) 관리 API" + name = "도서(UserBooks)", + description = "유저 저장 도서(user_books) 및 서재 매핑(bookshelf_items) 관리 API" ) @RestController @RequiredArgsConstructor @@ -35,8 +38,7 @@ public class UserBooksController { summary = "책 저장(내 저장 도서로 추가)", description = """ book을 유저의 '저장 도서(user_books)'로 추가합니다. - - - 헤더: X-USER-ID (필수) + - 인증: Access Token(Bearer) - Body: - bookId: 필수 - shelfId: 선택 (값이 있으면 해당 서재에 추가, A 방식) @@ -46,60 +48,51 @@ public class UserBooksController { @ApiResponses({ @ApiResponse(responseCode = "200", description = "성공(생성된 userBookId 반환)"), @ApiResponse(responseCode = "400", description = "요청 값 오류/유효성 실패"), + @ApiResponse(responseCode = "401", description = "인증 실패"), @ApiResponse(responseCode = "403", description = "내 서재가 아님"), - @ApiResponse(responseCode = "404", description = "책/서재/유저 없음"), + @ApiResponse(responseCode = "404", description = "책/서재 없음"), @ApiResponse(responseCode = "409", description = "이미 저장한 책(중복)") }) @PostMapping public UserBookCreateResponse create( - @Parameter(name = "X-USER-ID", description = "유저 식별자(필수)", required = true, example = "1") - @RequestHeader(name = "X-USER-ID") Long userId, - + @AuthenticationPrincipal CustomUserDetails userDetails, @RequestBody @Valid UserBookCreateRequest req ) { - return userBooksService.create(userId, req); + return userBooksService.create(userDetails.getUserId(), req); } @Operation( - summary = "저장 도서 목록 조회(전체)", + summary = "저장 도서 목록 조회", description = """ - 유저가 저장한 도서를 전체 조회합니다. (페이지네이션 없음) - - - 헤더: X-USER-ID (필수) + 유저가 저장한 도서를 조회합니다. + - 인증: Access Token(Bearer) - Query: - shelfId: 선택 - status: 선택 (TO_READ/READING/DONE/STOPPED) - sort: 선택 (LATEST/OLDEST/TITLE/AUTHOR), 기본 LATEST - - 응답: List + - 응답: 리스트 UI용 요약 데이터 """ ) @ApiResponses({ - @ApiResponse(responseCode = "200", description = "성공(리스트 반환)"), - @ApiResponse(responseCode = "400", description = "정렬/상태 값이 허용 범위를 벗어남") + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse(responseCode = "400", description = "정렬/상태 값 오류"), + @ApiResponse(responseCode = "401", description = "인증 실패") }) @GetMapping public UserBookListResponse list( - @Parameter(name = "X-USER-ID", description = "유저 식별자(필수)", required = true, example = "1") - @RequestHeader(name = "X-USER-ID") Long userId, - - @Parameter(description = "서재 ID(선택)", example = "10") + @AuthenticationPrincipal CustomUserDetails userDetails, @RequestParam(name = "shelfId", required = false) Long shelfId, - - @Parameter(description = "상태(선택)", example = "READING") @RequestParam(name = "status", required = false) ReadingStatus status, - - @Parameter(description = "정렬(선택). 기본 LATEST", example = "LATEST") @RequestParam(name = "sort", required = false, defaultValue = "LATEST") UserBookSort sort ) { - return userBooksService.listAll(userId, shelfId, status, sort); + return userBooksService.listAll(userDetails.getUserId(), shelfId, status, sort); } @Operation( - summary = "저장 도서 삭제(라이브러리 삭제 / 서재에서만 제거)", + summary = "저장 도서 삭제(선택/조건/전체)", description = """ 삭제 우선순위 - - 1) Body.ids가 있으면: 선택 삭제(라이브러리에서 완전 삭제) + 1) Body.ids가 있으면: 선택 삭제(라이브러리에서 완전 삭제) (ids: userBook의 id) 2) shelfId가 있으면: 해당 서재에서만 제거(라이브러리 유지) 3) status가 있으면: 상태별 전체 삭제(라이브러리에서 완전 삭제) 4) 모두 없으면: 전체 삭제(라이브러리 전체 삭제) @@ -108,24 +101,19 @@ public UserBookListResponse list( @ApiResponses({ @ApiResponse(responseCode = "200", description = "성공(삭제된 개수 반환)"), @ApiResponse(responseCode = "400", description = "파라미터 조합/값 오류"), + @ApiResponse(responseCode = "401", description = "인증 실패"), @ApiResponse(responseCode = "403", description = "내 서재가 아님"), @ApiResponse(responseCode = "404", description = "서재 없음") }) @DeleteMapping public int delete( - @Parameter(name = "X-USER-ID", description = "유저 식별자(필수)", required = true, example = "1") - @RequestHeader(name = "X-USER-ID") Long userId, - + @AuthenticationPrincipal CustomUserDetails userDetails, @RequestBody(required = false) DeleteBody body, - - @Parameter(description = "서재 ID(선택). 있으면 해당 서재에서만 제거", example = "10") @RequestParam(name = "shelfId", required = false) Long shelfId, - - @Parameter(description = "상태(선택). 있으면 해당 상태를 라이브러리에서 전체 삭제", example = "STOPPED") - @RequestParam(name = "status", required = false) ReadingStatus status + @RequestParam(name = "status", required = false) ReadingStatus status ) { List ids = (body == null) ? null : body.ids(); - return userBooksService.delete(userId, ids, shelfId, status); + return userBooksService.delete(userDetails.getUserId(), ids, shelfId, status); } @Schema(name = "DeleteBody", description = "저장 도서 삭제 요청 바디") @@ -138,31 +126,29 @@ public record DeleteBody( summary = "저장 도서 상세 조회", description = """ userBookId 기준으로 저장 도서 상세 정보를 조회합니다. - - - 헤더: X-USER-ID (필수) + - 인증: Access Token(Bearer) - Path: userBookId - - 응답: UserBookDetailResponse + - 응답: 상세 화면용 데이터(책 메타 + 내 상태 + 최근 로그 등) """ ) @ApiResponses({ - @ApiResponse(responseCode = "200", description = "성공(상세 반환)"), - @ApiResponse(responseCode = "404", description = "저장 도서 없음") + @ApiResponse(responseCode = "200", description = "성공"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "저장 도서 없음/권한 없음") }) @GetMapping("/{userBookId}") public UserBookDetailResponse detail( - @Parameter(name = "X-USER-ID", description = "유저 식별자(필수)", required = true, example = "1") - @RequestHeader(name = "X-USER-ID") Long userId, - - @Parameter(name = "userBookId", description = "저장 도서 ID(필수)", required = true, example = "100") + @AuthenticationPrincipal CustomUserDetails userDetails, @PathVariable(name = "userBookId") Long userBookId ) { - return userBooksService.detail(userId, userBookId); + return userBooksService.detail(userDetails.getUserId(), userBookId); } @Operation( summary = "도서 총 페이지 입력", description = """ 사용자가 직접 입력하는 총 페이지 수(pageCountSnapshot)를 저장합니다. + - 인증: Access Token(Bearer) - 책 메타(books)와 무관하게 user_books에 스냅샷으로 저장됩니다. - 입력 후 progressPercent는 currentPage 기준으로 재계산됩니다. """ @@ -170,49 +156,44 @@ public UserBookDetailResponse detail( @ApiResponses({ @ApiResponse(responseCode="204", description="저장 성공", content=@Content), @ApiResponse(responseCode="400", description="요청값 오류", content=@Content), + @ApiResponse(responseCode="401", description="인증 실패", content=@Content), @ApiResponse(responseCode="404", description="저장 도서 없음/권한 없음", content=@Content) }) @PatchMapping(value = "/{userBookId}/total-page", consumes = MediaType.APPLICATION_JSON_VALUE) @ResponseStatus(HttpStatus.NO_CONTENT) public void saveTotalPage( - @Parameter(name = "X-USER-ID", description = "유저 식별자(필수)", required = true, example = "1") - @RequestHeader("X-USER-ID") Long userId, - - @Parameter(description = "저장 도서 ID(user_books.user_book_id)", required = true, example = "101") - @PathVariable Long userBookId, - + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable(name = "userBookId") Long userBookId, @RequestBody @Valid TotalPageSaveRequest req ) { - userBooksService.saveTotalPage(userId, userBookId, req); + userBooksService.saveTotalPage(userDetails.getUserId(), userBookId, req); } @Operation( summary = "저장 도서 수정(상태/책종류 변경, 서재 추가)", description = """ user_books의 일부 필드만 변경합니다. + - 인증: Access Token(Bearer) - status: 읽기 상태 변경 - format: 책 종류(종이/전자/오디오) 변경 - shelfId: (A방식) 해당 서재에 '추가' (이동 아님) + - 응답: 204 No Content """ ) @ApiResponses({ @ApiResponse(responseCode="204", description="수정 성공", content=@Content), @ApiResponse(responseCode="400", description="요청값 오류", content=@Content), + @ApiResponse(responseCode="401", description="인증 실패", content=@Content), @ApiResponse(responseCode="403", description="내 서재가 아님", content=@Content), @ApiResponse(responseCode="404", description="저장 도서/서재 없음", content=@Content) }) @PatchMapping(value = "/{userBookId}", consumes = MediaType.APPLICATION_JSON_VALUE) @ResponseStatus(HttpStatus.NO_CONTENT) public void update( - @Parameter(name = "X-USER-ID", description = "유저 식별자(필수)", required = true, example = "1") - @RequestHeader("X-USER-ID") Long userId, - - @Parameter(description = "저장 도서 ID(user_books.user_book_id)", required = true, example = "101") - @PathVariable Long userBookId, + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable(name = "userBookId") Long userBookId, @RequestBody @Valid UserBookUpdateRequest req ) { - userBooksService.update(userId, userBookId, req); + userBooksService.update(userDetails.getUserId(), userBookId, req); } - - } diff --git a/booklog/src/main/java/com/example/booklog/domain/library/shelves/entity/Bookshelves.java b/booklog/src/main/java/com/example/booklog/domain/library/shelves/entity/Bookshelves.java index a5331d8..0aac08d 100644 --- a/booklog/src/main/java/com/example/booklog/domain/library/shelves/entity/Bookshelves.java +++ b/booklog/src/main/java/com/example/booklog/domain/library/shelves/entity/Bookshelves.java @@ -29,6 +29,7 @@ public class Bookshelves extends BaseEntity { @Column(name = "is_public", nullable = false) private boolean isPublic; + @Enumerated(EnumType.STRING) @Column(name = "sort_order", length = 20, nullable = false) private UserBookSort sortOrder; // ERD: VARCHAR(20) diff --git a/booklog/src/main/java/com/example/booklog/domain/library/shelves/entity/UserBooks.java b/booklog/src/main/java/com/example/booklog/domain/library/shelves/entity/UserBooks.java index ddab81c..b8c463c 100644 --- a/booklog/src/main/java/com/example/booklog/domain/library/shelves/entity/UserBooks.java +++ b/booklog/src/main/java/com/example/booklog/domain/library/shelves/entity/UserBooks.java @@ -35,6 +35,7 @@ public class UserBooks extends BaseEntity { @JoinColumn(name = "book_id", nullable = false, foreignKey = @ForeignKey(name = "fk_user_books_book")) private Books book; + @Enumerated(EnumType.STRING) @Column(name = "status", length = 20, nullable = false) private ReadingStatus status; // TO_READ/READING/DONE/STOPPED @@ -51,6 +52,7 @@ public class UserBooks extends BaseEntity { @Column(name = "end_date") private LocalDate endDate; + @Enumerated(EnumType.STRING) @Column(name = "format", length = 20) private BookFormat format; // PAPER/EBOOK/AUDIO 등 diff --git a/booklog/src/main/java/com/example/booklog/domain/library/shelves/repository/ReadingLogsRepository.java b/booklog/src/main/java/com/example/booklog/domain/library/shelves/repository/ReadingLogsRepository.java index 82d0370..50d08d1 100644 --- a/booklog/src/main/java/com/example/booklog/domain/library/shelves/repository/ReadingLogsRepository.java +++ b/booklog/src/main/java/com/example/booklog/domain/library/shelves/repository/ReadingLogsRepository.java @@ -4,12 +4,12 @@ import org.springframework.data.jpa.repository.*; import org.springframework.data.repository.query.Param; +import java.time.LocalDate; import java.util.List; import java.util.Optional; public interface ReadingLogsRepository extends JpaRepository { - // 로그 소유권 체크(= 내 userBook의 로그인지) @Query(""" select rl from ReadingLogs rl @@ -23,4 +23,41 @@ public interface ReadingLogsRepository extends JpaRepository List findByUserBook_IdOrderByReadDateAscCreatedAtAsc(Long userBookId); Optional findTopByUserBook_IdOrderByReadDateDescCreatedAtDesc(Long userBookId); + + /** 캘린더: 날짜별 최신 로그의 썸네일 1개 */ + interface CalendarDayThumbnailRow { + LocalDate getReadDate(); + String getThumbnailUrl(); + } + + @Query(""" + select + rl.readDate as readDate, + b.thumbnailUrl as thumbnailUrl + from ReadingLogs rl + join rl.userBook ub + join ub.book b + where ub.user.id = :userId + and rl.readDate >= :startDate + and rl.readDate < :endDate + and b.thumbnailUrl is not null + and not exists ( + select 1 + from ReadingLogs rl2 + join rl2.userBook ub2 + where ub2.user.id = :userId + and rl2.readDate = rl.readDate + and ( + rl2.createdAt > rl.createdAt + or (rl2.createdAt = rl.createdAt and rl2.id > rl.id) + ) + ) + order by rl.readDate asc +""") + List findCalendarDayThumbnails( + @Param("userId") Long userId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); + } diff --git a/booklog/src/main/java/com/example/booklog/domain/library/shelves/repository/UserBooksRepository.java b/booklog/src/main/java/com/example/booklog/domain/library/shelves/repository/UserBooksRepository.java index ee44a16..612fe27 100644 --- a/booklog/src/main/java/com/example/booklog/domain/library/shelves/repository/UserBooksRepository.java +++ b/booklog/src/main/java/com/example/booklog/domain/library/shelves/repository/UserBooksRepository.java @@ -87,7 +87,7 @@ List list( List listOrderByAuthorAsc( @Param("userId") Long userId, @Param("shelfId") Long shelfId, - @Param("status") String status + @Param("status") ReadingStatus status ); diff --git a/booklog/src/main/java/com/example/booklog/domain/users/controller/MeAvatarController.java b/booklog/src/main/java/com/example/booklog/domain/users/controller/MeAvatarController.java new file mode 100644 index 0000000..65a9433 --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/users/controller/MeAvatarController.java @@ -0,0 +1,50 @@ +// ===================================== +// [마이페이지] MeAvatarController +// ===================================== +package com.example.booklog.domain.users.controller; + +import com.example.booklog.domain.users.dto.MeAvatarUpdateResponse; +import com.example.booklog.domain.users.service.MeAvatarService; +import com.example.booklog.global.auth.security.CustomUserDetails; +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.context.annotation.Profile; +import org.springframework.http.MediaType; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name="마이페이지(Me) - Avatar", description="프로필 이미지 업로드 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/me") +@Profile("!local") // 기존 정책 유지 (local에서는 Swagger에도 안 뜸) +public class MeAvatarController { + + private final MeAvatarService meAvatarService; + + @Operation( + summary = "프로필 사진 업로드", + description = """ + 내 프로필 사진을 업로드하고 URL을 갱신합니다. + - 인증: Access Token(Bearer) + - Content-Type: multipart/form-data + - Part: file(필수) + - 응답: 업로드된 이미지 URL 반환 + """ + ) + @ApiResponses({ + @ApiResponse(responseCode="200", description="성공"), + @ApiResponse(responseCode="400", description="파일 누락/형식 오류"), + @ApiResponse(responseCode="401", description="인증 실패") + }) + @PutMapping(value = "/profile/avatar", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public MeAvatarUpdateResponse updateAvatar( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestPart("file") MultipartFile file + ) { + return meAvatarService.updateAvatar(userDetails.getUserId(), file); + } +} diff --git a/booklog/src/main/java/com/example/booklog/domain/users/controller/MeProfileController.java b/booklog/src/main/java/com/example/booklog/domain/users/controller/MeProfileController.java new file mode 100644 index 0000000..28dbbb9 --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/users/controller/MeProfileController.java @@ -0,0 +1,65 @@ +// ===================================== +// [마이페이지] MeProfileController +// ===================================== +package com.example.booklog.domain.users.controller; + +import com.example.booklog.domain.users.dto.MeProfileResponse; +import com.example.booklog.domain.users.dto.MeProfileUpdateRequest; +import com.example.booklog.domain.users.service.MeProfileService; +import com.example.booklog.global.auth.security.CustomUserDetails; +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.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name="마이페이지(Me) - Profile", description="내 프로필 조회/수정 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/me") +public class MeProfileController { + + private final MeProfileService meProfileService; + + @Operation( + summary = "내 프로필 조회", + description = """ + 프로필 편집 화면 진입 시 내 프로필 정보를 조회합니다. + - 인증: Access Token(Bearer) + - 응답: 닉네임/공개토글/프로필 이미지 등 + """ + ) + @ApiResponses({ + @ApiResponse(responseCode="200", description="성공"), + @ApiResponse(responseCode="401", description="인증 실패") + }) + @GetMapping("/profile") + public MeProfileResponse getMyProfile( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + return meProfileService.getMyProfile(userDetails.getUserId()); + } + + @Operation( + summary = "내 프로필 수정", + description = """ + 내 프로필 정보를 수정합니다. + - 인증: Access Token(Bearer) + - Body: nickname/isShelfPublic/isBooklogPublic 중 변경할 값만 전달 + - 응답: 수정 반영된 프로필 반환 + """ + ) + @ApiResponses({ + @ApiResponse(responseCode="200", description="성공"), + @ApiResponse(responseCode="400", description="요청값 오류(닉네임 정책 등)"), + @ApiResponse(responseCode="401", description="인증 실패") + }) + @PatchMapping("/profile") + public MeProfileResponse updateProfile( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody MeProfileUpdateRequest req + ) { + return meProfileService.updateProfile(userDetails.getUserId(), req); + } +} diff --git a/booklog/src/main/java/com/example/booklog/domain/users/controller/MeReadingCalendarController.java b/booklog/src/main/java/com/example/booklog/domain/users/controller/MeReadingCalendarController.java new file mode 100644 index 0000000..aa6d59a --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/users/controller/MeReadingCalendarController.java @@ -0,0 +1,44 @@ +// ===================================== +// [마이페이지] MeReadingCalendarController +// ===================================== +package com.example.booklog.domain.users.controller; + +import com.example.booklog.domain.users.dto.ReadingCalendarResponse; +import com.example.booklog.domain.users.service.ReadingCalendarService; +import com.example.booklog.global.auth.security.CustomUserDetails; +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.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "마이페이지(Me) - Reading Calendar", description = "마이페이지 독서 캘린더 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/me") +public class MeReadingCalendarController { + + private final ReadingCalendarService readingCalendarService; + + @Operation( + summary = "독서 캘린더 조회", + description = """ + month=YYYY-MM (없으면 현재달). + 날짜별 최신 독서기록의 책 썸네일 1개 반환 + - 인증: Access Token(Bearer) + """ + ) + @ApiResponses({ + @ApiResponse(responseCode="200", description="성공"), + @ApiResponse(responseCode="400", description="month 형식 오류(YYYY-MM)"), + @ApiResponse(responseCode="401", description="인증 실패") + }) + @GetMapping("/reading-calendar") + public ReadingCalendarResponse getReadingCalendar( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestParam(required = false) String month + ) { + return readingCalendarService.getCalendar(userDetails.getUserId(), month); + } +} diff --git a/booklog/src/main/java/com/example/booklog/domain/users/dto/MeAvatarUpdateResponse.java b/booklog/src/main/java/com/example/booklog/domain/users/dto/MeAvatarUpdateResponse.java new file mode 100644 index 0000000..d517458 --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/users/dto/MeAvatarUpdateResponse.java @@ -0,0 +1,6 @@ +package com.example.booklog.domain.users.dto; + +public record MeAvatarUpdateResponse( + Long userId, + String profileImageUrl +) {} \ No newline at end of file diff --git a/booklog/src/main/java/com/example/booklog/domain/users/dto/MeProfileResponse.java b/booklog/src/main/java/com/example/booklog/domain/users/dto/MeProfileResponse.java new file mode 100644 index 0000000..a6553fa --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/users/dto/MeProfileResponse.java @@ -0,0 +1,9 @@ +package com.example.booklog.domain.users.dto; + +public record MeProfileResponse( + Long userId, + String nickname, + String profileImageUrl, + Boolean isShelfPublic, + Boolean isBooklogPublic +) {} \ No newline at end of file diff --git a/booklog/src/main/java/com/example/booklog/domain/users/dto/MeProfileUpdateRequest.java b/booklog/src/main/java/com/example/booklog/domain/users/dto/MeProfileUpdateRequest.java index d934980..19b3d3d 100644 --- a/booklog/src/main/java/com/example/booklog/domain/users/dto/MeProfileUpdateRequest.java +++ b/booklog/src/main/java/com/example/booklog/domain/users/dto/MeProfileUpdateRequest.java @@ -1,4 +1,7 @@ package com.example.booklog.domain.users.dto; -public record MeProfileUpdateRequest() { -} +public record MeProfileUpdateRequest( + String nickname, + Boolean isShelfPublic, + Boolean isBooklogPublic +) {} \ No newline at end of file diff --git a/booklog/src/main/java/com/example/booklog/domain/users/dto/ReadingCalendarResponse.java b/booklog/src/main/java/com/example/booklog/domain/users/dto/ReadingCalendarResponse.java new file mode 100644 index 0000000..35b3303 --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/users/dto/ReadingCalendarResponse.java @@ -0,0 +1,14 @@ +package com.example.booklog.domain.users.dto; + +import java.time.LocalDate; +import java.util.List; + +public record ReadingCalendarResponse( + String month, // "YYYY-MM" + List days +) { + public record DayItem( + LocalDate date, + String thumbnailUrl // ✅ 날짜별 대표 썸네일 1개 + ) {} +} diff --git a/booklog/src/main/java/com/example/booklog/domain/users/dto/ReadingCalendarViewAllResponse.java b/booklog/src/main/java/com/example/booklog/domain/users/dto/ReadingCalendarViewAllResponse.java new file mode 100644 index 0000000..b6d1cbc --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/users/dto/ReadingCalendarViewAllResponse.java @@ -0,0 +1,6 @@ +package com.example.booklog.domain.users.dto; + +public record ReadingCalendarViewAllResponse( + ReadingCalendarResponse calendar, + Integer progressPercent +) {} \ No newline at end of file diff --git a/booklog/src/main/java/com/example/booklog/domain/users/service/MeAvatarService.java b/booklog/src/main/java/com/example/booklog/domain/users/service/MeAvatarService.java new file mode 100644 index 0000000..6765c96 --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/users/service/MeAvatarService.java @@ -0,0 +1,63 @@ +package com.example.booklog.domain.users.service; + +import com.example.booklog.aws.s3.AmazonS3Manager; +import com.example.booklog.domain.users.dto.MeAvatarUpdateResponse; +import com.example.booklog.domain.users.entity.Users; +import com.example.booklog.domain.users.repository.UsersRepository; +import com.example.booklog.global.common.Uuid; +import com.example.booklog.global.common.repository.UuidRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.Set; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Transactional +@Profile("!local") +public class MeAvatarService { + + private final UsersRepository usersRepository; + private final UuidRepository uuidRepository; + private final AmazonS3Manager amazonS3Manager; + + private static final long MAX_SIZE = 5L * 1024 * 1024; // 5MB + private static final Set ALLOWED = Set.of("image/jpeg", "image/png", "image/webp"); + + public MeAvatarUpdateResponse updateAvatar(Long userId, MultipartFile file) { + validate(file); + + Users user = usersRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("USER_NOT_FOUND")); + + // 1) Uuid 생성/저장 (Builder 사용) + Uuid uuid = uuidRepository.save( + Uuid.builder() + .uuid(UUID.randomUUID().toString()) + .build() + ); + + // 2) S3 key 생성 + 업로드 + String key = amazonS3Manager.generateProfileKeyName(uuid); + String url = amazonS3Manager.uploadFile(key, file); + + // 3) Users 갱신 (닉네임 유지, 이미지만 변경) + user.updateProfile(user.getNickname(), url); + + return new MeAvatarUpdateResponse(user.getId(), user.getProfileImageUrl()); + } + + private void validate(MultipartFile file) { + if (file == null || file.isEmpty()) throw new IllegalArgumentException("FILE_REQUIRED"); + if (file.getSize() > MAX_SIZE) throw new IllegalArgumentException("FILE_TOO_LARGE"); + + String ct = file.getContentType(); + if (ct == null || !ALLOWED.contains(ct)) { + throw new IllegalArgumentException("UNSUPPORTED_IMAGE_TYPE"); + } + } +} diff --git a/booklog/src/main/java/com/example/booklog/domain/users/service/MeProfileService.java b/booklog/src/main/java/com/example/booklog/domain/users/service/MeProfileService.java new file mode 100644 index 0000000..9bf7e04 --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/users/service/MeProfileService.java @@ -0,0 +1,83 @@ +package com.example.booklog.domain.users.service; + +import com.example.booklog.domain.users.dto.MeProfileResponse; +import com.example.booklog.domain.users.dto.MeProfileUpdateRequest; +import com.example.booklog.domain.users.entity.UserSettings; +import com.example.booklog.domain.users.entity.Users; +import com.example.booklog.domain.users.repository.UserSettingsRepository; +import com.example.booklog.domain.users.repository.UsersRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class MeProfileService { + + private final UsersRepository usersRepository; + private final UserSettingsRepository userSettingsRepository; + + @Transactional(readOnly = true) + public MeProfileResponse getMyProfile(Long userId) { + Users user = usersRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("USER_NOT_FOUND")); + + UserSettings settings = userSettingsRepository.findById(userId) + .orElseGet(() -> null); + + // settings가 없을 수도 있으니 기본값 정책 지정(현재 엔티티 기본값 true) + boolean shelfPublic = settings != null ? Boolean.TRUE.equals(settings.getIsShelfPublic()) : true; + boolean booklogPublic = settings != null ? Boolean.TRUE.equals(settings.getIsPostPublic()) : true; + + return new MeProfileResponse( + user.getId(), + user.getNickname(), + user.getProfileImageUrl(), + shelfPublic, + booklogPublic + ); + } + + @Transactional + public MeProfileResponse updateProfile(Long userId, MeProfileUpdateRequest req) { + Users user = usersRepository.findById(userId) + .orElseThrow(() -> new IllegalArgumentException("USER_NOT_FOUND")); + + // settings upsert (없으면 생성) + UserSettings settings = userSettingsRepository.findById(userId) + .orElseGet(() -> userSettingsRepository.save( + UserSettings.builder() + .user(user) + .isShelfPublic(true) + .isPostPublic(true) + .build() + )); + + // 1) nickname PATCH: null이면 변경 X, 값이 오면 검증 후 변경 + if (req.nickname() != null) { + String nn = req.nickname().trim(); + if (nn.isEmpty()) throw new IllegalArgumentException("NICKNAME_EMPTY"); + if (nn.length() > 50) throw new IllegalArgumentException("NICKNAME_TOO_LONG"); + + // profileImageUrl은 유지 (사진은 PUT /avatar) + user.updateProfile(nn, user.getProfileImageUrl()); + } + + // 2) 공개 토글 PATCH + if (req.isShelfPublic() != null) { + settings.updateShelfPublic(req.isShelfPublic()); + } + if (req.isBooklogPublic() != null) { + // DTO: isBooklogPublic -> Entity: isPostPublic + settings.updatePostPublic(req.isBooklogPublic()); + } + + return new MeProfileResponse( + user.getId(), + user.getNickname(), + user.getProfileImageUrl(), + settings.getIsShelfPublic(), + settings.getIsPostPublic() + ); + } +} diff --git a/booklog/src/main/java/com/example/booklog/domain/users/service/ReadingCalendarService.java b/booklog/src/main/java/com/example/booklog/domain/users/service/ReadingCalendarService.java new file mode 100644 index 0000000..44e5ebe --- /dev/null +++ b/booklog/src/main/java/com/example/booklog/domain/users/service/ReadingCalendarService.java @@ -0,0 +1,49 @@ +package com.example.booklog.domain.users.service; + +import com.example.booklog.domain.library.shelves.repository.ReadingLogsRepository; +import com.example.booklog.domain.users.dto.ReadingCalendarResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.ZoneId; +import java.time.format.DateTimeParseException; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReadingCalendarService { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + private final ReadingLogsRepository readingLogsRepository; + + public ReadingCalendarResponse getCalendar(Long userId, String month) { + YearMonth ym = (month == null || month.isBlank()) + ? YearMonth.now(KST) + : parseYearMonth(month); + + LocalDate start = ym.atDay(1); + LocalDate endExclusive = ym.plusMonths(1).atDay(1); + + List rows = + readingLogsRepository.findCalendarDayThumbnails(userId, start, endExclusive); + + List days = rows.stream() + .map(r -> new ReadingCalendarResponse.DayItem(r.getReadDate(), r.getThumbnailUrl())) + .toList(); + + return new ReadingCalendarResponse(ym.toString(), days); + } + + private YearMonth parseYearMonth(String month) { + try { + return YearMonth.parse(month); // "YYYY-MM" + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("month 형식이 올바르지 않습니다. 예) 2026-01"); + } + } +}