diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index ce95785..2a44099 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -45,7 +45,12 @@ jobs: - name: Create application-oauth.yml run: | echo "${{ secrets.APPLICATION_OAUTH_YML }}" > src/main/resources/application-oauth.yml - + + # 3-3. application-book.yml 생성 + - name: Create application-book.yml + run: | + echo "${{ secrets.APPLICATION_BOOK_YML }}" > src/main/resources/application-book.yml + # 4. Gradle 실행 권한 부여 - name: Grant execute permission for gradlew run: chmod +x gradlew diff --git a/build.gradle b/build.gradle index 549096c..586ca31 100644 --- a/build.gradle +++ b/build.gradle @@ -50,9 +50,12 @@ dependencies { //implementation 'com.mysql:mysql-connector-j:9.1.0' // H2 - runtimeOnly 'com.h2database:h2' + //runtimeOnly 'com.h2database:h2' - testImplementation 'org.springframework.boot:spring-boot-starter-test' + // MariaDB + runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/src/main/java/com/moongeul/backend/api/book/controller/BookController.java b/src/main/java/com/moongeul/backend/api/book/controller/BookController.java new file mode 100644 index 0000000..95ab8e8 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/book/controller/BookController.java @@ -0,0 +1,52 @@ +package com.moongeul.backend.api.book.controller; + +import com.moongeul.backend.api.book.dto.BookSearchRequestDTO; +import com.moongeul.backend.api.book.dto.BookSearchResponseDTO; +import com.moongeul.backend.api.book.service.BookService; +import com.moongeul.backend.common.response.ApiResponse; +import com.moongeul.backend.common.response.SuccessStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Book", description = "Book(도서) 관련 API 입니다.") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v2/book") +@Validated +public class BookController { + + private final BookService bookService; + + @Operation( + summary = "도서 검색 API", + description = "네이버 도서 API를 활용하여 책 제목으로 도서를 검색합니다. 검색 결과는 DB에 저장되며, 이미 저장된 도서는 최신 정보로 업데이트됩니다. (페이지와 사이즈는 1부터 시작합니다)" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "도서 검색 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청 파라미터"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 오류 발생") + }) + @GetMapping("/search") + public ResponseEntity> searchBooks( + @RequestParam @NotBlank(message = "검색어는 필수입니다.") String query, + @RequestParam(required = false, defaultValue = "1") @Min(value = 1, message = "페이지는 1 이상이어야 합니다.") Integer page, + @RequestParam(required = false, defaultValue = "10") @Min(value = 1, message = "한 페이지당 개수는 1 이상이어야 합니다.") Integer size) { + + BookSearchRequestDTO bookSearchRequestDTO = BookSearchRequestDTO.builder() + .query(query) + .page(page) + .size(size) + .build(); + + BookSearchResponseDTO bookSearchResponseDTO = bookService.searchBooks(bookSearchRequestDTO); + return ApiResponse.success(SuccessStatus.SEARCH_BOOK_SUCCESS, bookSearchResponseDTO); + } +} + diff --git a/src/main/java/com/moongeul/backend/api/book/dto/BookDTO.java b/src/main/java/com/moongeul/backend/api/book/dto/BookDTO.java new file mode 100644 index 0000000..a948c53 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/book/dto/BookDTO.java @@ -0,0 +1,23 @@ +package com.moongeul.backend.api.book.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BookDTO { + private String isbn; // ISBN + private String title; // 책 제목 + private String author; // 저자 + private String bookImage; // 표지 이미지 + private String publisher; // 출판사 + private String description; // 책 소개 + private String pubdate; // 출판연도 + private Double ratingAverage; // 별점 평균 + private Integer ratingCount; // 별점 개수 +} + diff --git a/src/main/java/com/moongeul/backend/api/book/dto/BookSearchRequestDTO.java b/src/main/java/com/moongeul/backend/api/book/dto/BookSearchRequestDTO.java new file mode 100644 index 0000000..5ff5cd2 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/book/dto/BookSearchRequestDTO.java @@ -0,0 +1,24 @@ +package com.moongeul.backend.api.book.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BookSearchRequestDTO { + @NotBlank(message = "검색어는 필수입니다.") + private String query; // 검색어 + + @Min(value = 1, message = "페이지는 1 이상이어야 합니다.") + private Integer page = 1; // 페이지 번호 (기본값 1) + + @Min(value = 1, message = "한 페이지당 개수는 1 이상이어야 합니다.") + private Integer size = 10; // 한 페이지당 개수 (기본값 10, 최대 100) +} + diff --git a/src/main/java/com/moongeul/backend/api/book/dto/BookSearchResponseDTO.java b/src/main/java/com/moongeul/backend/api/book/dto/BookSearchResponseDTO.java new file mode 100644 index 0000000..fc74361 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/book/dto/BookSearchResponseDTO.java @@ -0,0 +1,22 @@ +package com.moongeul.backend.api.book.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class BookSearchResponseDTO { + private Integer total; // 전체 검색 결과 수 + private Integer page; // 현재 페이지 + private Integer size; // 페이지당 개수 + private Integer totalPages; // 전체 페이지 수 + private Boolean isLast; // 마지막 페이지 여부 + private List books; // 책 목록 +} + diff --git a/src/main/java/com/moongeul/backend/api/book/dto/NaverBookItemDTO.java b/src/main/java/com/moongeul/backend/api/book/dto/NaverBookItemDTO.java new file mode 100644 index 0000000..3391ca2 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/book/dto/NaverBookItemDTO.java @@ -0,0 +1,41 @@ +package com.moongeul.backend.api.book.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NaverBookItemDTO { + @JsonProperty("title") + private String title; // 책 제목 + + @JsonProperty("link") + private String link; // 네이버 도서 정보 URL + + @JsonProperty("image") + private String image; // 표지 이미지 URL + + @JsonProperty("author") + private String author; // 저자 + + @JsonProperty("discount") + private String discount; // 할인가격 + + @JsonProperty("publisher") + private String publisher; // 출판사 + + @JsonProperty("pubdate") + private String pubdate; // 출판연도 + + @JsonProperty("isbn") + private String isbn; // ISBN + + @JsonProperty("description") + private String description; // 책 소개 +} + diff --git a/src/main/java/com/moongeul/backend/api/book/dto/NaverBookSearchResponseDTO.java b/src/main/java/com/moongeul/backend/api/book/dto/NaverBookSearchResponseDTO.java new file mode 100644 index 0000000..118ba06 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/book/dto/NaverBookSearchResponseDTO.java @@ -0,0 +1,31 @@ +package com.moongeul.backend.api.book.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class NaverBookSearchResponseDTO { + @JsonProperty("lastBuildDate") + private String lastBuildDate; + + @JsonProperty("total") + private Integer total; + + @JsonProperty("start") + private Integer start; + + @JsonProperty("display") + private Integer display; + + @JsonProperty("items") + private List items; +} + diff --git a/src/main/java/com/moongeul/backend/api/book/entity/Book.java b/src/main/java/com/moongeul/backend/api/book/entity/Book.java new file mode 100644 index 0000000..ecda1cc --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/book/entity/Book.java @@ -0,0 +1,43 @@ +package com.moongeul.backend.api.book.entity; + +import com.moongeul.backend.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "book") +public class Book extends BaseTimeEntity { + + @Id + private String isbn; // ISBN (PK) + + private String title; // 책 제목 + private String author; // 저자 + private String bookImage; // 표지 + private String publisher; // 출판사 + + @Column(columnDefinition = "TEXT") + private String description; // 책 소개 + private String pubdate; // 출판연도 + private Double ratingAverage; // 별점 평균 + private Integer ratingCount; // 별점 개수 + + // 책 정보 업데이트 + public void update(String title, String author, String bookImage, String publisher, + String description, String pubdate) { + this.title = title; + this.author = author; + this.bookImage = bookImage; + this.publisher = publisher; + this.description = description; + this.pubdate = pubdate; + } +} + diff --git a/src/main/java/com/moongeul/backend/api/book/repository/BookRepository.java b/src/main/java/com/moongeul/backend/api/book/repository/BookRepository.java new file mode 100644 index 0000000..6535089 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/book/repository/BookRepository.java @@ -0,0 +1,14 @@ +package com.moongeul.backend.api.book.repository; + +import com.moongeul.backend.api.book.entity.Book; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface BookRepository extends JpaRepository { + Optional findByIsbn(String isbn); + + List findByIsbnIn(List isbns); +} + diff --git a/src/main/java/com/moongeul/backend/api/book/service/BookService.java b/src/main/java/com/moongeul/backend/api/book/service/BookService.java new file mode 100644 index 0000000..ff7d0c1 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/book/service/BookService.java @@ -0,0 +1,259 @@ +package com.moongeul.backend.api.book.service; + +import com.moongeul.backend.api.book.dto.*; +import com.moongeul.backend.api.book.entity.Book; +import com.moongeul.backend.api.book.repository.BookRepository; +import com.moongeul.backend.common.exception.InternalServerException; +import com.moongeul.backend.common.response.ErrorStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.UriComponentsBuilder; +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.net.URI; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class BookService { + + private final WebClient webClient; + private final BookRepository bookRepository; + + @Value("${naver.book.client-id}") + private String clientId; + + @Value("${naver.book.client-secret}") + private String clientSecret; + + @Transactional + public BookSearchResponseDTO searchBooks(BookSearchRequestDTO bookSearchRequestDTO) { + // 네이버 API 호출 + NaverBookSearchResponseDTO naverBookSearchResponseDTO = callNaverBookAPI(bookSearchRequestDTO); + + if (naverBookSearchResponseDTO.getItems() == null || naverBookSearchResponseDTO.getItems().isEmpty()) { + return BookSearchResponseDTO.builder() + .books(new ArrayList<>()) + .total(0) + .page(bookSearchRequestDTO.getPage()) + .size(bookSearchRequestDTO.getSize()) + .totalPages(0) + .isLast(true) + .build(); + } + + // ISBN 리스트 추출 (첫 번째 ISBN만 사용) + List isbns = naverBookSearchResponseDTO.getItems().stream() + .map(item -> { + if (item.getIsbn() == null || item.getIsbn().isEmpty()) { + return null; + } + // 네이버 API는 여러 ISBN을 공백으로 구분하므로 첫 번째 ISBN만 사용 + return item.getIsbn().split(" ")[0].trim(); + }) + .filter(isbn -> isbn != null && !isbn.isEmpty()) + .distinct() + .collect(Collectors.toList()); + + // DB에서 기존 책 조회 + List existingBooks = bookRepository.findByIsbnIn(isbns); + Map existingBookMap = existingBooks.stream() + .collect(Collectors.toMap(Book::getIsbn, book -> book)); + + // 책 정보 저장/업데이트 + List bookDTOs = new ArrayList<>(); + for (NaverBookItemDTO naverItem : naverBookSearchResponseDTO.getItems()) { + if (naverItem.getIsbn() == null || naverItem.getIsbn().isEmpty()) { + continue; + } + + // ISBN에서 공백 제거 및 첫 번째 ISBN만 사용 (네이버 API는 여러 ISBN을 공백으로 구분) + String isbn = naverItem.getIsbn().split(" ")[0].trim(); + + Book book = existingBookMap.get(isbn); + + if (book != null) { + // 기존 책이 있으면 업데이트 (최신 정보로) + updateBookIfChanged(book, naverItem); + } else { + // 새 책이면 저장 + book = saveNewBook(naverItem, isbn); + } + + bookDTOs.add(convertToDTO(book)); + } + + // 페이지네이션 정보 계산 + int total = naverBookSearchResponseDTO.getTotal() != null ? naverBookSearchResponseDTO.getTotal() : 0; + int totalPages = (int) Math.ceil((double) total / bookSearchRequestDTO.getSize()); + int start = (bookSearchRequestDTO.getPage() - 1) * bookSearchRequestDTO.getSize() + 1; + + // 마지막 페이지 여부 계산 + boolean isLast = (start + bookSearchRequestDTO.getSize() - 1) >= total; + + return BookSearchResponseDTO.builder() + .books(bookDTOs) + .total(total) + .page(bookSearchRequestDTO.getPage()) + .size(bookSearchRequestDTO.getSize()) + .totalPages(totalPages) + .isLast(isLast) + .build(); + } + + private NaverBookSearchResponseDTO callNaverBookAPI(BookSearchRequestDTO request) { + // 네이버 API 파라미터 설정 + int start = (request.getPage() - 1) * request.getSize() + 1; + int display = Math.min(request.getSize(), 100); // 네이버 API 최대 100개 + + // URI 생성 (보여준 코드 방식 적용) + URI uri = UriComponentsBuilder + .fromUriString("https://openapi.naver.com") + .path("/v1/search/book.json") + .queryParam("query", request.getQuery()) + .queryParam("display", display) + .queryParam("start", start) + .encode() + .build() + .toUri(); + + log.info("네이버 도서 API 호출 URL: {}", uri); + log.info("검색어: {}", request.getQuery()); + + try { + // 먼저 응답 본문을 String으로 받아서 확인 + String responseBody = webClient.get() + .uri(uri) + .header("X-Naver-Client-Id", clientId) + .header("X-Naver-Client-Secret", clientSecret) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, clientResponse -> { + log.error("네이버 API 4xx 에러 발생: {}", clientResponse.statusCode()); + throw new InternalServerException(ErrorStatus.NAVER_SERVER_ERROR.getMessage()); + }) + .onStatus(HttpStatusCode::is5xxServerError, serverResponse -> { + log.error("네이버 API 5xx 에러 발생: {}", serverResponse.statusCode()); + throw new InternalServerException(ErrorStatus.NAVER_SERVER_ERROR.getMessage()); + }) + .bodyToMono(String.class) + .block(); + + log.info("네이버 API 원본 응답 (처음 500자): {}", + responseBody != null && responseBody.length() > 500 + ? responseBody.substring(0, 500) + : responseBody); + + // JSON 파싱 + ObjectMapper objectMapper = new ObjectMapper(); + NaverBookSearchResponseDTO response = objectMapper.readValue(responseBody, NaverBookSearchResponseDTO.class); + + if (response != null) { + log.info("네이버 API 응답 파싱 성공 - total: {}, start: {}, display: {}, items 수: {}", + response.getTotal(), + response.getStart(), + response.getDisplay(), + response.getItems() != null ? response.getItems().size() : 0); + } else { + log.warn("네이버 API 응답이 null입니다."); + } + return response; + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { + log.error("JSON 파싱 실패: {}", e.getMessage(), e); + throw new InternalServerException(ErrorStatus.NAVER_SERVER_ERROR.getMessage()); + } catch (Exception e) { + log.error("네이버 도서 API 호출 실패: {}", e.getMessage(), e); + e.printStackTrace(); + throw new InternalServerException(ErrorStatus.NAVER_SERVER_ERROR.getMessage()); + } + } + + private void updateBookIfChanged(Book book, NaverBookItemDTO naverItem) { + String cleanIsbn = naverItem.getIsbn().split(" ")[0].trim(); + String cleanTitle = truncateString(cleanHtmlTags(naverItem.getTitle()), 255); + String cleanAuthor = truncateString(cleanHtmlTags(naverItem.getAuthor()), 255); + String cleanPublisher = truncateString(cleanHtmlTags(naverItem.getPublisher()), 255); + String cleanDescription = cleanHtmlTags(naverItem.getDescription()); + + // 데이터가 변경되었는지 확인 + boolean changed = !cleanTitle.equals(book.getTitle()) || + !cleanAuthor.equals(book.getAuthor()) || + !cleanPublisher.equals(book.getPublisher()) || + !cleanDescription.equals(book.getDescription()) || + !naverItem.getImage().equals(book.getBookImage()) || + !naverItem.getPubdate().equals(book.getPubdate()); + + if (changed) { + book.update(cleanTitle, cleanAuthor, naverItem.getImage(), + cleanPublisher, cleanDescription, naverItem.getPubdate()); + bookRepository.save(book); + } + } + + private Book saveNewBook(NaverBookItemDTO naverItem, String isbn) { + String cleanTitle = truncateString(cleanHtmlTags(naverItem.getTitle()), 255); + String cleanAuthor = truncateString(cleanHtmlTags(naverItem.getAuthor()), 255); + String cleanPublisher = truncateString(cleanHtmlTags(naverItem.getPublisher()), 255); + String cleanDescription = cleanHtmlTags(naverItem.getDescription()); + String bookImage = truncateString(naverItem.getImage(), 255); + + Book newBook = Book.builder() + .isbn(isbn) + .title(cleanTitle) + .author(cleanAuthor) + .bookImage(bookImage) + .publisher(cleanPublisher) + .description(cleanDescription) + .pubdate(naverItem.getPubdate()) + .ratingAverage(0.0) + .ratingCount(0) + .build(); + + return bookRepository.save(newBook); + } + + private BookDTO convertToDTO(Book book) { + return BookDTO.builder() + .isbn(book.getIsbn()) + .title(book.getTitle()) + .author(book.getAuthor()) + .bookImage(book.getBookImage()) + .publisher(book.getPublisher()) + .description(book.getDescription()) + .pubdate(book.getPubdate()) + .ratingAverage(book.getRatingAverage()) + .ratingCount(book.getRatingCount()) + .build(); + } + + // 네이버 API 응답에서 HTML 태그 제거 + private String cleanHtmlTags(String text) { + if (text == null) { + return ""; + } + return text.replaceAll("<[^>]*>", "").trim(); + } + + // 문자열을 지정된 길이로 자르기 + private String truncateString(String text, int maxLength) { + if (text == null) { + return ""; + } + if (text.length() <= maxLength) { + return text; + } + return text.substring(0, maxLength); + } +} + diff --git a/src/main/java/com/moongeul/backend/api/bookshelf/controller/WishReadBookshelfController.java b/src/main/java/com/moongeul/backend/api/bookshelf/controller/WishReadBookshelfController.java new file mode 100644 index 0000000..05e0606 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/bookshelf/controller/WishReadBookshelfController.java @@ -0,0 +1,59 @@ +package com.moongeul.backend.api.bookshelf.controller; + +import com.moongeul.backend.api.bookshelf.dto.WishReadBookshelfRequestDTO; +import com.moongeul.backend.api.bookshelf.service.WishReadBookshelfService; +import com.moongeul.backend.common.response.ApiResponse; +import com.moongeul.backend.common.response.SuccessStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "WishReadBookshelf", description = "읽고 싶은 책장 관련 API 입니다.") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v2/bookshelf/wish-read") +public class WishReadBookshelfController { + + private final WishReadBookshelfService wishReadBookshelfService; + + @Operation( + summary = "읽고 싶은 책 등록 API", + description = "ISBN을 받아서 읽고 싶은 책장에 등록합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "읽고 싶은 책 등록 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "이미 등록된 도서이거나 잘못된 요청"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "사용자 또는 도서를 찾을 수 없습니다.") + }) + @PostMapping + public ResponseEntity> addWishReadBook( + @AuthenticationPrincipal UserDetails userDetails, + @Valid @RequestBody WishReadBookshelfRequestDTO wishReadBookshelfRequestDTO) { + + wishReadBookshelfService.addWishReadBook(userDetails.getUsername(), wishReadBookshelfRequestDTO.getIsbn()); + return ApiResponse.success_only(SuccessStatus.ADD_WISH_READ_BOOK_SUCCESS); + } + + @Operation( + summary = "읽고 싶은 책 삭제 API", + description = "ISBN을 받아서 읽고 싶은 책장에서 삭제합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "읽고 싶은 책 삭제 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "사용자 또는 도서를 찾을 수 없습니다.") + }) + @DeleteMapping + public ResponseEntity> removeWishReadBook( + @AuthenticationPrincipal UserDetails userDetails, + @Valid @RequestBody WishReadBookshelfRequestDTO wishReadBookshelfRequestDTO) { + + wishReadBookshelfService.removeWishReadBook(userDetails.getUsername(), wishReadBookshelfRequestDTO.getIsbn()); + return ApiResponse.success_only(SuccessStatus.REMOVE_WISH_READ_BOOK_SUCCESS); + } +} diff --git a/src/main/java/com/moongeul/backend/api/bookshelf/dto/WishReadBookshelfRequestDTO.java b/src/main/java/com/moongeul/backend/api/bookshelf/dto/WishReadBookshelfRequestDTO.java new file mode 100644 index 0000000..a27d96c --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/bookshelf/dto/WishReadBookshelfRequestDTO.java @@ -0,0 +1,17 @@ +package com.moongeul.backend.api.bookshelf.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WishReadBookshelfRequestDTO { + @NotBlank(message = "ISBN은 필수입니다.") + private String isbn; +} + diff --git a/src/main/java/com/moongeul/backend/api/bookshelf/entity/WishReadBookshelf.java b/src/main/java/com/moongeul/backend/api/bookshelf/entity/WishReadBookshelf.java new file mode 100644 index 0000000..90dd91d --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/bookshelf/entity/WishReadBookshelf.java @@ -0,0 +1,33 @@ +package com.moongeul.backend.api.bookshelf.entity; + +import com.moongeul.backend.api.book.entity.Book; +import com.moongeul.backend.api.member.entity.Member; +import com.moongeul.backend.common.entity.BaseTimeEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "wish_read_bookshelf", uniqueConstraints = { + @UniqueConstraint(columnNames = {"member_id", "book_isbn"}) +}) +public class WishReadBookshelf extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "book_isbn", nullable = false) + private Book book; +} diff --git a/src/main/java/com/moongeul/backend/api/bookshelf/repository/WishReadBookshelfRepository.java b/src/main/java/com/moongeul/backend/api/bookshelf/repository/WishReadBookshelfRepository.java new file mode 100644 index 0000000..078a728 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/bookshelf/repository/WishReadBookshelfRepository.java @@ -0,0 +1,16 @@ +package com.moongeul.backend.api.bookshelf.repository; + +import com.moongeul.backend.api.bookshelf.entity.WishReadBookshelf; +import com.moongeul.backend.api.book.entity.Book; +import com.moongeul.backend.api.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface WishReadBookshelfRepository extends JpaRepository { + Optional findByMemberAndBook(Member member, Book book); + + boolean existsByMemberAndBook(Member member, Book book); + void deleteByMemberAndBook(Member member, Book book); +} + diff --git a/src/main/java/com/moongeul/backend/api/bookshelf/service/WishReadBookshelfService.java b/src/main/java/com/moongeul/backend/api/bookshelf/service/WishReadBookshelfService.java new file mode 100644 index 0000000..dca9172 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/bookshelf/service/WishReadBookshelfService.java @@ -0,0 +1,66 @@ +package com.moongeul.backend.api.bookshelf.service; + +import com.moongeul.backend.api.book.entity.Book; +import com.moongeul.backend.api.book.repository.BookRepository; +import com.moongeul.backend.api.bookshelf.entity.WishReadBookshelf; +import com.moongeul.backend.api.bookshelf.repository.WishReadBookshelfRepository; +import com.moongeul.backend.api.member.entity.Member; +import com.moongeul.backend.api.member.repository.MemberRepository; +import com.moongeul.backend.common.exception.BadRequestException; +import com.moongeul.backend.common.exception.NotFoundException; +import com.moongeul.backend.common.response.ErrorStatus; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class WishReadBookshelfService { + + private final WishReadBookshelfRepository wishReadBookshelfRepository; + private final MemberRepository memberRepository; + private final BookRepository bookRepository; + + @Transactional + public void addWishReadBook(String email, String isbn) { + + // 회원 조회 + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new NotFoundException(ErrorStatus.USER_NOTFOUND_EXCEPTION.getMessage())); + + // 책 조회 + Book book = bookRepository.findByIsbn(isbn) + .orElseThrow(() -> new NotFoundException(ErrorStatus.BOOK_NOTFOUND_EXCEPTION.getMessage())); + + // 이미 등록되어 있는지 확인 + if (wishReadBookshelfRepository.existsByMemberAndBook(member, book)) { + throw new BadRequestException(ErrorStatus.BOOK_ALREADY_ADDED_EXCEPTION.getMessage()); + } + + // 읽고 싶은 책 등록 + WishReadBookshelf wishReadBookshelf = WishReadBookshelf.builder() + .member(member) + .book(book) + .build(); + + wishReadBookshelfRepository.save(wishReadBookshelf); + } + + @Transactional + public void removeWishReadBook(String email, String isbn) { + + // 회원 조회 + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new NotFoundException(ErrorStatus.USER_NOTFOUND_EXCEPTION.getMessage())); + + // 책 조회 + Book book = bookRepository.findByIsbn(isbn) + .orElseThrow(() -> new NotFoundException(ErrorStatus.BOOK_NOTFOUND_EXCEPTION.getMessage())); + + // 읽고 싶은 책 삭제 + wishReadBookshelfRepository.deleteByMemberAndBook(member, book); + } +} + diff --git a/src/main/java/com/moongeul/backend/common/config/oauth2/SecurityConfig.java b/src/main/java/com/moongeul/backend/common/config/security/SecurityConfig.java similarity index 97% rename from src/main/java/com/moongeul/backend/common/config/oauth2/SecurityConfig.java rename to src/main/java/com/moongeul/backend/common/config/security/SecurityConfig.java index 4c4f015..7478be6 100644 --- a/src/main/java/com/moongeul/backend/common/config/oauth2/SecurityConfig.java +++ b/src/main/java/com/moongeul/backend/common/config/security/SecurityConfig.java @@ -1,4 +1,4 @@ -package com.moongeul.backend.common.config.oauth2; +package com.moongeul.backend.common.config.security; import com.moongeul.backend.api.member.jwt.filter.JwtAuthenticationFilter; import com.moongeul.backend.common.config.jwt.JwtTokenProvider; diff --git a/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java b/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java index eb32a4b..f873a50 100644 --- a/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java +++ b/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java @@ -29,12 +29,19 @@ public enum ErrorStatus { * 404 NOT_FOUND */ USER_NOTFOUND_EXCEPTION(HttpStatus.NOT_FOUND,"해당 사용자를 찾을 수 없습니다."), + BOOK_NOTFOUND_EXCEPTION(HttpStatus.NOT_FOUND, "해당 도서를 찾을 수 없습니다."), + + /** + * 400 BAD_REQUEST (추가) + */ + BOOK_ALREADY_ADDED_EXCEPTION(HttpStatus.BAD_REQUEST, "이미 읽고 싶은 책으로 등록된 도서입니다."), /** * 500 SERVER_ERROR */ INTERNAL_SERVER_EXCEPTION(HttpStatus.INTERNAL_SERVER_ERROR,"서버 내부 오류 발생"), - SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "로그인 서버 오류 발생") + SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "로그인 서버 오류 발생"), + NAVER_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "네이버 서버 오류 발생"), ; diff --git a/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java b/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java index 60885d9..7d4df05 100644 --- a/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java +++ b/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java @@ -15,7 +15,10 @@ public enum SuccessStatus { SEND_HEALTH_CHECK_SUCCESS(HttpStatus.OK,"서버 상태 체크 성공"), SEND_LOGIN_SUCCESS(HttpStatus.OK, "로그인 성공"), GET_USERINFO_SUCCESS(HttpStatus.OK, "사용자 정보 조회 성공"), - REISSUE_TOKEN_SUCCESS(HttpStatus.OK, "토큰 재발급 성공") + SEARCH_BOOK_SUCCESS(HttpStatus.OK, "도서 검색 성공"), + ADD_WISH_READ_BOOK_SUCCESS(HttpStatus.OK, "읽고 싶은 책 등록 성공"), + REMOVE_WISH_READ_BOOK_SUCCESS(HttpStatus.OK, "읽고 싶은 책 삭제 성공"), + REISSUE_TOKEN_SUCCESS(HttpStatus.OK, "토큰 재발급 성공"), /** * 201