diff --git a/src/main/java/com/hansung/leafly/domain/book/service/BookService.java b/src/main/java/com/hansung/leafly/domain/book/service/BookService.java new file mode 100644 index 0000000..dd28fb0 --- /dev/null +++ b/src/main/java/com/hansung/leafly/domain/book/service/BookService.java @@ -0,0 +1,9 @@ +package com.hansung.leafly.domain.book.service; + +import com.hansung.leafly.domain.book.web.dto.SearchRes; + +import java.util.List; + +public interface BookService { + List search(String keyword); +} diff --git a/src/main/java/com/hansung/leafly/domain/book/service/BookServiceImpl.java b/src/main/java/com/hansung/leafly/domain/book/service/BookServiceImpl.java new file mode 100644 index 0000000..5a084bc --- /dev/null +++ b/src/main/java/com/hansung/leafly/domain/book/service/BookServiceImpl.java @@ -0,0 +1,36 @@ +package com.hansung.leafly.domain.book.service; + +import com.hansung.leafly.domain.book.web.dto.AladinSearchResponse; +import com.hansung.leafly.domain.book.web.dto.BookRes; +import com.hansung.leafly.domain.book.web.dto.SearchRes; +import com.hansung.leafly.infra.aladin.AladinClient; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BookServiceImpl implements BookService { + private final AladinClient aladinClient; + + // 제목 검색 + @Override + public List search(String keyword) { + AladinSearchResponse response = aladinClient.search(keyword); + + if (response == null || response.item() == null) { + return List.of(); + } + + return response.item().stream() + .map(item -> SearchRes.from( + item, + false //좋아요 기능 구현 후 수정 필요 + )) + .toList(); + } +} diff --git a/src/main/java/com/hansung/leafly/domain/book/web/controller/BookController.java b/src/main/java/com/hansung/leafly/domain/book/web/controller/BookController.java new file mode 100644 index 0000000..5c0ebcc --- /dev/null +++ b/src/main/java/com/hansung/leafly/domain/book/web/controller/BookController.java @@ -0,0 +1,33 @@ +package com.hansung.leafly.domain.book.web.controller; + +import com.hansung.leafly.domain.book.service.BookService; +import com.hansung.leafly.domain.book.web.dto.SearchRes; +import com.hansung.leafly.global.response.SuccessResponse; +import jakarta.validation.constraints.Size; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/api/books") +@RequiredArgsConstructor +@Validated +public class BookController { + private final BookService bookService; + + @GetMapping + public ResponseEntity>> search( + @RequestParam @Size(min = 2, message = "검색어는 2글자 이상이어야 합니다.") + String keyword + ){ + List res = bookService.search(keyword); + return ResponseEntity.status(HttpStatus.OK).body(SuccessResponse.from(res)); + } +} diff --git a/src/main/java/com/hansung/leafly/domain/book/web/dto/AladinBookItem.java b/src/main/java/com/hansung/leafly/domain/book/web/dto/AladinBookItem.java new file mode 100644 index 0000000..df99639 --- /dev/null +++ b/src/main/java/com/hansung/leafly/domain/book/web/dto/AladinBookItem.java @@ -0,0 +1,14 @@ +package com.hansung.leafly.domain.book.web.dto; + +public record AladinBookItem( + String title, // 책 제목 + String author, // 저자 + String publisher, // 출판사 + String pubDate, // 출판일 (yyyy-MM-dd) + String description, // 책 설명 + String isbn, // ISBN10 + String isbn13, // ISBN13 + String cover, // 표지 URL + String categoryName, // 카테고리명 (예: 컴퓨터/IT > 소프트웨어) + double customerReviewRank // 알라딘 사용자 평점 +) {} diff --git a/src/main/java/com/hansung/leafly/domain/book/web/dto/AladinSearchResponse.java b/src/main/java/com/hansung/leafly/domain/book/web/dto/AladinSearchResponse.java new file mode 100644 index 0000000..d5e160d --- /dev/null +++ b/src/main/java/com/hansung/leafly/domain/book/web/dto/AladinSearchResponse.java @@ -0,0 +1,8 @@ +package com.hansung.leafly.domain.book.web.dto; + +import java.util.List; + +public record AladinSearchResponse( + List item +) {} + diff --git a/src/main/java/com/hansung/leafly/domain/book/web/dto/BookRes.java b/src/main/java/com/hansung/leafly/domain/book/web/dto/BookRes.java new file mode 100644 index 0000000..96e83f7 --- /dev/null +++ b/src/main/java/com/hansung/leafly/domain/book/web/dto/BookRes.java @@ -0,0 +1,64 @@ +package com.hansung.leafly.domain.book.web.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class BookRes { + + @JsonProperty("item") + private List items; + + public List getItems() { + return items; + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Item { + private String title; + private String author; + private String pubDate; + private String publisher; + private String isbn; + private String isbn13; + private String description; + private String cover; + private Integer priceStandard; + private Integer priceSales; + private Double customerReviewRank; + private Integer categoryId; + private String categoryName; + private SubInfo subInfo; + + // ✅ Getter & Setter + public String getTitle() { return title; } + public String getAuthor() { return author; } + public String getPubDate() { return pubDate; } + public String getPublisher() { return publisher; } + public String getIsbn() { return isbn; } + public String getIsbn13() { return isbn13; } + public String getDescription() { return description; } + public String getCover() { return cover; } + public Integer getPriceStandard() { return priceStandard; } + public Integer getPriceSales() { return priceSales; } + public Double getCustomerReviewRank() { return customerReviewRank; } + public Integer getCategoryId() { return categoryId; } + public String getCategoryName() { return categoryName; } + public void setSubInfo(SubInfo subInfo) { this.subInfo = subInfo; } + + // ✅ 책 실제 쪽수 반환 (subInfo.itemPage) + public Integer getItemPage() { + return (subInfo != null) ? subInfo.getItemPage() : null; + } + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public static class SubInfo { + private Integer itemPage; // 실제 책의 쪽수 + + public Integer getItemPage() { return itemPage; } + public void setItemPage(Integer itemPage) { this.itemPage = itemPage; } + } +} diff --git a/src/main/java/com/hansung/leafly/domain/book/web/dto/SearchRes.java b/src/main/java/com/hansung/leafly/domain/book/web/dto/SearchRes.java new file mode 100644 index 0000000..691d6de --- /dev/null +++ b/src/main/java/com/hansung/leafly/domain/book/web/dto/SearchRes.java @@ -0,0 +1,21 @@ +package com.hansung.leafly.domain.book.web.dto; + +public record SearchRes ( + String isbn, + String title, + String author, + String cover, + double rating, + boolean isLiked +){ + public static SearchRes from(AladinBookItem item, boolean isLiked) { + return new SearchRes( + item.isbn13(), + item.title(), + item.author(), + item.cover(), + item.customerReviewRank(), + isLiked + ); + } +} diff --git a/src/main/java/com/hansung/leafly/global/exception/GlobalExceptionHandler.java b/src/main/java/com/hansung/leafly/global/exception/GlobalExceptionHandler.java index 8ce9947..e7c9c32 100644 --- a/src/main/java/com/hansung/leafly/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/hansung/leafly/global/exception/GlobalExceptionHandler.java @@ -2,6 +2,7 @@ import com.hansung.leafly.global.response.ErrorResponse; import com.hansung.leafly.global.response.code.ErrorResponseCode; +import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -103,6 +104,24 @@ public ResponseEntity> handleBaseException(BaseException e){ return ResponseEntity.status(errorResponse.getHttpStatus()).body(errorResponse); } + // RequestParam, PathVariable 등 검증(@Validated) 실패 시 발생 + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity> handleConstraintViolationException(ConstraintViolationException e) { + log.error("ConstraintViolationException : {}", e.getMessage(), e); + + // 여러 위반 중 첫 번째 메시지 선택 + String message = e.getConstraintViolations().stream() + .findFirst() + .map(v -> v.getMessage()) + .orElse("유효하지 않은 요청 파라미터입니다."); + + ErrorResponse errorResponse = ErrorResponse.of( + ErrorResponseCode.INVALID_HTTP_MESSAGE_PARAMETER, + message + ); + return ResponseEntity.status(errorResponse.getHttpStatus()).body(errorResponse); + } + // 나머지 예외 처리 @ExceptionHandler(Exception.class) public ResponseEntity> handleException(Exception e){ diff --git a/src/main/java/com/hansung/leafly/infra/aladin/AladinClient.java b/src/main/java/com/hansung/leafly/infra/aladin/AladinClient.java new file mode 100644 index 0000000..16da32b --- /dev/null +++ b/src/main/java/com/hansung/leafly/infra/aladin/AladinClient.java @@ -0,0 +1,53 @@ +package com.hansung.leafly.infra.aladin; + +import com.hansung.leafly.domain.book.web.dto.AladinSearchResponse; +import com.hansung.leafly.domain.book.web.dto.BookRes; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +@Component +public class AladinClient { + + @Value("${spring.aladin.isbn.url}") + private String ISBN_URL; + + @Value("${spring.aladin.search.url}") + private String SEARCH_URL; + + @Value("${spring.aladin.key}") + private String TTB_KEY; + + private final RestTemplate restTemplate = new RestTemplate(); + + public BookRes fetchBookByIsbn(String isbn) { + String uri = UriComponentsBuilder.fromHttpUrl(ISBN_URL) + .queryParam("ttbkey", TTB_KEY) + .queryParam("itemIdType", "ISBN13") + .queryParam("ItemId", isbn) + .queryParam("output", "js") + .queryParam("Version", "20131101") + .queryParam("Cover", "Big") + .build() + .toUriString(); + + return restTemplate.getForObject(uri, BookRes.class); + } + + public AladinSearchResponse search(String keyword) { + String uri = UriComponentsBuilder.fromHttpUrl(SEARCH_URL) + .queryParam("ttbkey", TTB_KEY) + .queryParam("Query", keyword) + .queryParam("QueryType", "Title") + .queryParam("SearchTarget", "Book") + .queryParam("MaxResults", 20) + .queryParam("output", "js") + .queryParam("Version", "20131101") + .build() + .toUriString(); + + return restTemplate.getForObject(uri, AladinSearchResponse.class); + } +} + diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 7d3081e..c529e2e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -16,4 +16,9 @@ spring.jpa.properties.hibernate.use_sql_comments=true #JWT jwt.secret=${jwt.secret} -jwt.expiration=${jwt.expiration} \ No newline at end of file +jwt.expiration=${jwt.expiration} + +#Aladin +spring.aladin.isbn.url=${ISBN_URL} +spring.aladin.search.url=${SEARCH_URL} +spring.aladin.key=${ALADIN_API_KEY} \ No newline at end of file