diff --git a/src/main/java/com/hansung/leafly/domain/book/exception/BookErrorCode.java b/src/main/java/com/hansung/leafly/domain/book/exception/BookErrorCode.java new file mode 100644 index 0000000..cabf665 --- /dev/null +++ b/src/main/java/com/hansung/leafly/domain/book/exception/BookErrorCode.java @@ -0,0 +1,16 @@ +package com.hansung.leafly.domain.book.exception; + +import com.hansung.leafly.global.response.code.BaseResponseCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@AllArgsConstructor +public enum BookErrorCode implements BaseResponseCode{ + BOOK_NOT_FOUND("BOOK_404_1", 404, "해당 ISBN으로 검색된 책이 없습니다."); + + private final String code; + private final int httpStatus; + private final String message; +} diff --git a/src/main/java/com/hansung/leafly/domain/book/exception/BookNotFoundException.java b/src/main/java/com/hansung/leafly/domain/book/exception/BookNotFoundException.java new file mode 100644 index 0000000..ca2666a --- /dev/null +++ b/src/main/java/com/hansung/leafly/domain/book/exception/BookNotFoundException.java @@ -0,0 +1,9 @@ +package com.hansung.leafly.domain.book.exception; + +import com.hansung.leafly.global.exception.BaseException; + +public class BookNotFoundException extends BaseException { + public BookNotFoundException() { + super(BookErrorCode.BOOK_NOT_FOUND); + } +} 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 index c6a5a4d..ec5ebbc 100644 --- a/src/main/java/com/hansung/leafly/domain/book/service/BookService.java +++ b/src/main/java/com/hansung/leafly/domain/book/service/BookService.java @@ -1,10 +1,13 @@ package com.hansung.leafly.domain.book.service; import com.hansung.leafly.domain.book.web.dto.BookFilterReq; +import com.hansung.leafly.domain.book.web.dto.BookInfoRes; import com.hansung.leafly.domain.book.web.dto.SearchRes; import java.util.List; public interface BookService { List search(String keyword, BookFilterReq req); + + BookInfoRes details(Long isbn); } 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 index 7828082..beadafa 100644 --- a/src/main/java/com/hansung/leafly/domain/book/service/BookServiceImpl.java +++ b/src/main/java/com/hansung/leafly/domain/book/service/BookServiceImpl.java @@ -1,13 +1,16 @@ package com.hansung.leafly.domain.book.service; import com.hansung.leafly.domain.book.entity.enums.BookGenre; +import com.hansung.leafly.domain.book.exception.BookNotFoundException; import com.hansung.leafly.domain.book.web.dto.*; import com.hansung.leafly.infra.aladin.AladinClient; +import com.hansung.leafly.infra.aladin.dto.BookRes; +import com.hansung.leafly.infra.openai.OpenAiClient; +import com.hansung.leafly.infra.openai.dto.BookSummaryAiRes; +import com.hansung.leafly.infra.openai.prompt.RecommendationPrompt; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.util.UriComponentsBuilder; import java.util.List; @@ -15,11 +18,12 @@ @RequiredArgsConstructor public class BookServiceImpl implements BookService { private final AladinClient aladinClient; + private final OpenAiClient openAiClient; // 검색 @Override public List search(String keyword, BookFilterReq req) { - AladinSearchResponse response = aladinClient.search(keyword); + AladinSearchRes response = aladinClient.search(keyword); if (response == null || response.item() == null) { return List.of(); @@ -39,6 +43,39 @@ public List search(String keyword, BookFilterReq req) { .toList(); } + @Override + public BookInfoRes details(Long isbn) { + // ISBN 기반 상세 정보 조회 + BookRes bookRes = aladinClient.fetchBookByIsbn(String.valueOf(isbn)); + if (bookRes == null || + bookRes.getItems() == null || + bookRes.getItems().isEmpty() || + bookRes.getItems().get(0) == null) { + throw new BookNotFoundException(); + } + + BookDetailRes detail = BookDetailRes.from(bookRes); + + // BookSummaryPrompt 생성 → AI 요약 + 태그 생성 + String prompt = RecommendationPrompt.build(detail); + BookSummaryAiRes summaryAi = openAiClient.summarizeBook(prompt); + + // 해당 카테고리 기반 추천 목록 검색 + String categoryId = String.valueOf(bookRes.getItems().get(0).getCategoryId()); + AladinSearchRes categorySearchRes = aladinClient.searchByCategory(categoryId); + List recommendations = toRecommendationList(categorySearchRes); + + //boolean liked = likeService.isBookLiked(memberId, isbn13); + + return BookInfoRes.of( + detail, + summaryAi.summary(), + summaryAi.tags(), + recommendations, + false + ); + } + //카테고리 필터링 private boolean matchesGenre(AladinBookItem item, List targetGenres) { String middle = extractMiddleCategory(item.categoryName()); @@ -57,4 +94,15 @@ private String extractMiddleCategory(String categoryName) { String[] tokens = categoryName.split(">"); return tokens.length >= 2 ? tokens[1].trim() : ""; } + + private List toRecommendationList(AladinSearchRes res) { + return res.item().stream() + .map(item -> new RecommendRes( + item.isbn13(), + item.title(), + item.author(), + item.cover() + )) + .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 index 93ece24..df45a66 100644 --- 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 @@ -2,6 +2,7 @@ import com.hansung.leafly.domain.book.service.BookService; import com.hansung.leafly.domain.book.web.dto.BookFilterReq; +import com.hansung.leafly.domain.book.web.dto.BookInfoRes; import com.hansung.leafly.domain.book.web.dto.SearchRes; import com.hansung.leafly.global.response.SuccessResponse; import jakarta.validation.Valid; @@ -30,4 +31,13 @@ public ResponseEntity>> search( List res = bookService.search(keyword, req); return ResponseEntity.status(HttpStatus.OK).body(SuccessResponse.from(res)); } + + @GetMapping("/{isbn}") + public ResponseEntity> details( + @PathVariable Long isbn + ){ + BookInfoRes res = bookService.details(isbn); + return ResponseEntity.status(HttpStatus.OK).body(SuccessResponse.from(res)); + } + } 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/AladinSearchRes.java similarity index 75% rename from src/main/java/com/hansung/leafly/domain/book/web/dto/AladinSearchResponse.java rename to src/main/java/com/hansung/leafly/domain/book/web/dto/AladinSearchRes.java index d5e160d..8f49bd9 100644 --- a/src/main/java/com/hansung/leafly/domain/book/web/dto/AladinSearchResponse.java +++ b/src/main/java/com/hansung/leafly/domain/book/web/dto/AladinSearchRes.java @@ -2,7 +2,7 @@ import java.util.List; -public record AladinSearchResponse( +public record AladinSearchRes( List item ) {} diff --git a/src/main/java/com/hansung/leafly/domain/book/web/dto/BookDetailRes.java b/src/main/java/com/hansung/leafly/domain/book/web/dto/BookDetailRes.java new file mode 100644 index 0000000..4a77d87 --- /dev/null +++ b/src/main/java/com/hansung/leafly/domain/book/web/dto/BookDetailRes.java @@ -0,0 +1,31 @@ +package com.hansung.leafly.domain.book.web.dto; + +import com.hansung.leafly.infra.aladin.dto.BookRes; + +public record BookDetailRes( + String title, //책 제목 + String author, //책 저자 + String publisher, //출판사 + String pubDate, //출판일 + String description, //간략 소개 + String isbn13, //isbn값 + String cover, //책표지 + int priceStandard, //원가 + int priceSales //할인가 +) { + public static BookDetailRes from(BookRes res) { + BookRes.Item item = res.getItems().get(0); + + return new BookDetailRes( + item.getTitle(), + item.getAuthor(), + item.getPublisher(), + item.getPubDate(), + item.getDescription(), + item.getIsbn13(), + item.getCover(), + item.getPriceStandard(), + item.getPriceSales() + ); + } +} diff --git a/src/main/java/com/hansung/leafly/domain/book/web/dto/BookInfoRes.java b/src/main/java/com/hansung/leafly/domain/book/web/dto/BookInfoRes.java new file mode 100644 index 0000000..7bef6ea --- /dev/null +++ b/src/main/java/com/hansung/leafly/domain/book/web/dto/BookInfoRes.java @@ -0,0 +1,26 @@ +package com.hansung.leafly.domain.book.web.dto; + +import java.util.List; + +public record BookInfoRes( + BookDetailRes bookDetail, // 책 상세 정보 + String aiSummary, // AI가 생성한 요약 문장 + List aiTags, // AI가 선정한 태그들 + List recommendations, // 추천 책 리스트 + boolean isLiked +){ + public static BookInfoRes of( + BookDetailRes bookDetailRes, + String aiSummary, + List aiTags, + List recommendations, + boolean isLiked + ) { + return new BookInfoRes( + bookDetailRes, + aiSummary, + aiTags, + recommendations, + isLiked + ); +}} 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 deleted file mode 100644 index 96e83f7..0000000 --- a/src/main/java/com/hansung/leafly/domain/book/web/dto/BookRes.java +++ /dev/null @@ -1,64 +0,0 @@ -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/RecommendRes.java b/src/main/java/com/hansung/leafly/domain/book/web/dto/RecommendRes.java new file mode 100644 index 0000000..e564022 --- /dev/null +++ b/src/main/java/com/hansung/leafly/domain/book/web/dto/RecommendRes.java @@ -0,0 +1,8 @@ +package com.hansung.leafly.domain.book.web.dto; + +public record RecommendRes ( + String isbn, + String title, + String author, + String cover +) {} diff --git a/src/main/java/com/hansung/leafly/domain/recommend/service/RecommendServiceImpl.java b/src/main/java/com/hansung/leafly/domain/recommend/service/RecommendServiceImpl.java index 274cedc..5cd2ad9 100644 --- a/src/main/java/com/hansung/leafly/domain/recommend/service/RecommendServiceImpl.java +++ b/src/main/java/com/hansung/leafly/domain/recommend/service/RecommendServiceImpl.java @@ -1,6 +1,6 @@ package com.hansung.leafly.domain.recommend.service; -import com.hansung.leafly.domain.book.web.dto.AladinSearchResponse; +import com.hansung.leafly.domain.book.web.dto.AladinSearchRes; import com.hansung.leafly.domain.book.web.dto.SearchRes; import com.hansung.leafly.domain.member.entity.Member; import com.hansung.leafly.domain.member.entity.Onboarding; @@ -41,7 +41,7 @@ public List recommendations(Member member) { //실제 존재하는 책 필터링 for (var book : aiRes.books()) { - AladinSearchResponse searchRes = aladinClient.search(book.title()); + AladinSearchRes searchRes = aladinClient.search(book.title()); if (searchRes == null || searchRes.item() == null || searchRes.item().isEmpty()) { continue; diff --git a/src/main/java/com/hansung/leafly/infra/aladin/AladinClient.java b/src/main/java/com/hansung/leafly/infra/aladin/AladinClient.java index 16da32b..9b3aa41 100644 --- a/src/main/java/com/hansung/leafly/infra/aladin/AladinClient.java +++ b/src/main/java/com/hansung/leafly/infra/aladin/AladinClient.java @@ -1,7 +1,8 @@ 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 com.hansung.leafly.domain.book.web.dto.AladinSearchRes; +import com.hansung.leafly.domain.book.web.dto.RecommendRes; +import com.hansung.leafly.infra.aladin.dto.BookRes; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; @@ -35,7 +36,7 @@ public BookRes fetchBookByIsbn(String isbn) { return restTemplate.getForObject(uri, BookRes.class); } - public AladinSearchResponse search(String keyword) { + public AladinSearchRes search(String keyword) { String uri = UriComponentsBuilder.fromHttpUrl(SEARCH_URL) .queryParam("ttbkey", TTB_KEY) .queryParam("Query", keyword) @@ -47,7 +48,23 @@ public AladinSearchResponse search(String keyword) { .build() .toUriString(); - return restTemplate.getForObject(uri, AladinSearchResponse.class); + return restTemplate.getForObject(uri, AladinSearchRes.class); + } + + public AladinSearchRes searchByCategory(String categoryId) { + String uri = UriComponentsBuilder.fromHttpUrl(SEARCH_URL) + .queryParam("ttbkey", TTB_KEY) + .queryParam("Query", "*") // 전체 검색 + .queryParam("QueryType", "Keyword") + .queryParam("SearchTarget", "Book") + .queryParam("CategoryId", categoryId) + .queryParam("MaxResults", 6) + .queryParam("output", "js") + .queryParam("Version", "20131101") + .build() + .toUriString(); + + return restTemplate.getForObject(uri, AladinSearchRes.class); } } diff --git a/src/main/java/com/hansung/leafly/infra/aladin/dto/BookRes.java b/src/main/java/com/hansung/leafly/infra/aladin/dto/BookRes.java new file mode 100644 index 0000000..554f2e8 --- /dev/null +++ b/src/main/java/com/hansung/leafly/infra/aladin/dto/BookRes.java @@ -0,0 +1,40 @@ +package com.hansung.leafly.infra.aladin.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class BookRes { + + @JsonProperty("item") + private List items; + + public List getItems() { + return items; + } + + @Getter + @NoArgsConstructor + @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; + } +} diff --git a/src/main/java/com/hansung/leafly/infra/openai/OpenAiClient.java b/src/main/java/com/hansung/leafly/infra/openai/OpenAiClient.java index f78ee71..ed96646 100644 --- a/src/main/java/com/hansung/leafly/infra/openai/OpenAiClient.java +++ b/src/main/java/com/hansung/leafly/infra/openai/OpenAiClient.java @@ -2,9 +2,13 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.hansung.leafly.infra.openai.dto.BookSummaryAiRes; import com.hansung.leafly.infra.openai.dto.RecommendAiRes; import com.hansung.leafly.infra.openai.exception.OpenaiRequestFailed; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; @@ -20,7 +24,17 @@ public class OpenAiClient { private final RestTemplate restTemplate = new RestTemplate(); private final ObjectMapper mapper = new ObjectMapper(); + // AI 추천 책 리스트 public RecommendAiRes recommendBooks(String prompt) { + return callOpenAi(prompt, RecommendAiRes.class); + } + + public BookSummaryAiRes summarizeBook(String prompt) { + return callOpenAi(prompt, BookSummaryAiRes.class); + } + + // 공통 AI 호출 메소드 + private T callOpenAi(String prompt, Class responseType) { try { Map requestBody = Map.of( "model", "gpt-4o-mini", @@ -30,11 +44,11 @@ public RecommendAiRes recommendBooks(String prompt) { ) ); - var headers = new org.springframework.http.HttpHeaders(); + HttpHeaders headers = new HttpHeaders(); headers.setBearerAuth(apiKey); - headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON); + headers.setContentType(MediaType.APPLICATION_JSON); - var entity = new org.springframework.http.HttpEntity<>(requestBody, headers); + HttpEntity entity = new HttpEntity<>(requestBody, headers); String response = restTemplate.postForObject( "https://api.openai.com/v1/chat/completions", @@ -45,7 +59,7 @@ public RecommendAiRes recommendBooks(String prompt) { JsonNode root = mapper.readTree(response); String content = root.path("choices").get(0).path("message").path("content").asText(); - return mapper.readValue(content, RecommendAiRes.class); + return mapper.readValue(content, responseType); } catch (Exception e) { throw new OpenaiRequestFailed(); diff --git a/src/main/java/com/hansung/leafly/infra/openai/dto/BookSummaryAiRes.java b/src/main/java/com/hansung/leafly/infra/openai/dto/BookSummaryAiRes.java new file mode 100644 index 0000000..0eeafff --- /dev/null +++ b/src/main/java/com/hansung/leafly/infra/openai/dto/BookSummaryAiRes.java @@ -0,0 +1,8 @@ +package com.hansung.leafly.infra.openai.dto; + +import java.util.List; + +public record BookSummaryAiRes( + String summary, + List tags +) {} diff --git a/src/main/java/com/hansung/leafly/infra/openai/prompt/RecommendationPrompt.java b/src/main/java/com/hansung/leafly/infra/openai/prompt/RecommendationPrompt.java index 08bdbae..be459bc 100644 --- a/src/main/java/com/hansung/leafly/infra/openai/prompt/RecommendationPrompt.java +++ b/src/main/java/com/hansung/leafly/infra/openai/prompt/RecommendationPrompt.java @@ -1,5 +1,6 @@ package com.hansung.leafly.infra.openai.prompt; +import com.hansung.leafly.domain.book.web.dto.BookDetailRes; import com.hansung.leafly.domain.member.entity.Onboarding; public class RecommendationPrompt { @@ -48,4 +49,48 @@ public static String build(Onboarding onboarding) { onboarding.getReadingFrequency() ); } + + public static String build(BookDetailRes book) { + return """ + 당신은 한국 도서 전문 리뷰어이자 요약 전문가입니다. + 아래 책 정보를 기반으로 **정확하고 신뢰할 수 있는 요약(최소 2~3문장)**과 + **책의 핵심 주제나 톤을 반영하는 태그 최소 2~5개**를 생성해주세요. + + [책 정보] + - 제목: %s + - 저자: %s + - 출판사: %s + - 출판일: %s + - 책 설명: %s + + [요약 작성 기준] + 1. 줄거리를 요약하되 스포일러는 포함하지 않습니다. + 2. 책의 핵심 메시지·감정·분위기·주제를 자연스럽게 드러냅니다. + 3. 문장은 최소 2~3줄 분량으로 작성합니다. + (너무 짧은 한줄 요약 금지) + + [태그 작성 기준] + - #태그 형식으로 작성하세요. + - 최소 2개 이상, 최대 5개 생성하세요. + - 책의 성향/주제/톤/분위기/대상 독자 등을 나타내는 단어로 구성하세요. + - 예시: #우정 #감정 #청소년문학 #소설 #철학 #성장 #심리 + + [응답 형식(JSON)] + { + "summary": "여기에 책 요약(최소 2~3문장)", + "tags": ["#태그1", "#태그2", "#태그3"] + } + + 주의사항: + - 반드시 실제 책 설명을 기반으로 작성하세요. + - 출력은 반드시 JSON 형식만 유지하세요. + - 설명이 부족하면 일반적인 출판사 소개 문체로 자연스럽게 보완하세요. + """.formatted( + book.title(), + book.author(), + book.publisher(), + book.pubDate(), + book.description() + ); + } }