diff --git a/src/main/java/book/book/crawler/strategy/NaverBlogPostCrawlingStrategy.java b/src/main/java/book/book/crawler/strategy/NaverBlogPostCrawlingStrategy.java index e32390bf..58a73f6c 100644 --- a/src/main/java/book/book/crawler/strategy/NaverBlogPostCrawlingStrategy.java +++ b/src/main/java/book/book/crawler/strategy/NaverBlogPostCrawlingStrategy.java @@ -3,9 +3,6 @@ import book.book.book.entity.Book; import book.book.book.entity.BookCategory; import book.book.book.repository.BookCategoryRepository; -import book.book.book.repository.BookRepository; -import book.book.book.service.BookSaveService; -import book.book.book.service.BookService; import book.book.crawler.domain.CrawlResult; import book.book.crawler.domain.CrawledData; import book.book.crawler.domain.NaverBlog; @@ -16,7 +13,6 @@ import book.book.crawler.service.CrawlPersistenceService; import book.book.crawler.service.NaverBlogPostService; import book.book.crawler.service.NaverBlogService; -import book.book.search.dto.aladin.AladinSearchResponse; import book.book.search.service.AladinService; import java.net.MalformedURLException; import java.net.URL; @@ -34,11 +30,8 @@ public class NaverBlogPostCrawlingStrategy implements CrawlingStrategy { private final CrawlPersistenceService crawlPersistenceService; private final NaverBlogPostCrawlResultRepository naverBlogPostCrawlResultRepository; private final BookCategoryRepository bookCategoryRepository; - private final BookRepository bookRepository; private final AladinService aladinService; private final SkipBookCategoryRepository skipBookCategoryRepository; - private final BookSaveService bookSaveService; - private final BookService bookService; private String[] extractBlogId(String urlString) throws MalformedURLException { // TODO: 유닛테스트 @@ -137,7 +130,7 @@ private NaverBlogPostCrawlResult.SaveResult savePost(CrawledData crawledData, Na } String bookTitle = bookTitleOptional.get(); - Optional bookOptional = findOrCreateBook(bookTitle); + Optional bookOptional = aladinService.getOrSearchBook(bookTitle); if (bookOptional.isEmpty()) { log.warn("검색 결과가 없는 책 {}/{}", blogPost.getBlogId(), blogPost.getPostId()); return NaverBlogPostCrawlResult.SaveResult.NO_BOOK; @@ -159,27 +152,6 @@ private NaverBlogPostCrawlResult.SaveResult savePost(CrawledData crawledData, Na return NaverBlogPostCrawlResult.SaveResult.SUCCESS; } - private Optional findOrCreateBook(String bookTitle) { - Optional existingBook = bookRepository.findByTitle(bookTitle); - if (existingBook.isPresent()) { - return existingBook; - } - - try { - AladinSearchResponse searchResponse = aladinService.search(bookTitle, 1, 1); - if (searchResponse.getItem().isEmpty()) { - log.error("책 찾을 수 없음: {}", bookTitle); - return Optional.empty(); - } - - Book book = bookService.saveBooksParallel(searchResponse.getItem()).get(0); - return Optional.of(book); - } catch (Exception e) { - log.error("책 검색 중 오류 발생: {}, {}", e, bookTitle); - return Optional.empty(); - } - } - private boolean isQualifiedPost(NaverBlogPost post) { // TODO: 저품질 글을 거를 수 있는 조건 찾기 return post.getPostCommentCount() >= 0 && post.getPostLikeCount() >= 0; diff --git a/src/main/java/book/book/recommendation/api/RecommendationController.java b/src/main/java/book/book/recommendation/api/RecommendationController.java new file mode 100644 index 00000000..d9e96833 --- /dev/null +++ b/src/main/java/book/book/recommendation/api/RecommendationController.java @@ -0,0 +1,36 @@ +package book.book.recommendation.api; + +import book.book.book.dto.BookOverviewResponse; +import book.book.book.entity.Book; +import book.book.member.entity.Member; +import book.book.member.repository.MemberRepository; +import book.book.recommendation.dto.BookRecommendationResponse; +import book.book.recommendation.service.RecommendationService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/v1/recommendation") +@RequiredArgsConstructor +public class RecommendationController { + private final RecommendationService recommendationService; + private final MemberRepository memberRepository; + + @GetMapping("/members/{memberId}/books/refresh") + public List refreshBooksForMember(@PathVariable Long memberId) { + Member member = memberRepository.findByIdOrElseThrow(memberId); + List books = recommendationService.generateBookRecommendationForMember(member); + return books.stream().map(BookRecommendationResponse::from).toList(); + } + + @GetMapping("/members/{memberId}/books") + public List getBooksForMember(@PathVariable Long memberId) { + Member member = memberRepository.findByIdOrElseThrow(memberId); + List books = recommendationService.getLastRecommendedBooksForMember(member); + return books.stream().map(BookRecommendationResponse::from).toList(); + } +} diff --git a/src/main/java/book/book/recommendation/dto/BookRecommendationResponse.java b/src/main/java/book/book/recommendation/dto/BookRecommendationResponse.java new file mode 100644 index 00000000..2a5e469d --- /dev/null +++ b/src/main/java/book/book/recommendation/dto/BookRecommendationResponse.java @@ -0,0 +1,13 @@ +package book.book.recommendation.dto; + +import book.book.book.entity.Book; + +public record BookRecommendationResponse( + Long id, + String title, + String author +) { + public static BookRecommendationResponse from(Book book) { + return new BookRecommendationResponse(book.getId(), book.getTitle(), book.getAuthor()); + } +} diff --git a/src/main/java/book/book/recommendation/entity/MemberBookRecommendation.java b/src/main/java/book/book/recommendation/entity/MemberBookRecommendation.java new file mode 100644 index 00000000..27e72de0 --- /dev/null +++ b/src/main/java/book/book/recommendation/entity/MemberBookRecommendation.java @@ -0,0 +1,29 @@ +package book.book.recommendation.entity; + +import book.book.book.entity.Book; +import book.book.common.BaseTimeEntity; +import book.book.member.entity.Member; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class MemberBookRecommendation extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column + private Long collectionId; + + @ManyToOne + @JoinColumn(name = "book_id") + private Book book; + + @ManyToOne + @JoinColumn(name = "member_id") + private Member member; +} diff --git a/src/main/java/book/book/recommendation/repository/MemberBookRecommendationRepository.java b/src/main/java/book/book/recommendation/repository/MemberBookRecommendationRepository.java new file mode 100644 index 00000000..15bda05e --- /dev/null +++ b/src/main/java/book/book/recommendation/repository/MemberBookRecommendationRepository.java @@ -0,0 +1,18 @@ +package book.book.recommendation.repository; + +import book.book.member.entity.Member; +import book.book.recommendation.entity.MemberBookRecommendation; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface MemberBookRecommendationRepository extends JpaRepository { + @Query(value = "SELECT MAX(mbr.collectionId) FROM MemberBookRecommendation mbr WHERE mbr.member = :member") + Optional findLastCollectionIdByMember(Member member); + + List findByMemberAndCollectionId(Member member, Long collectionId); +} diff --git a/src/main/java/book/book/recommendation/service/BookRecommendationResponses.java b/src/main/java/book/book/recommendation/service/BookRecommendationResponses.java new file mode 100644 index 00000000..9fecb050 --- /dev/null +++ b/src/main/java/book/book/recommendation/service/BookRecommendationResponses.java @@ -0,0 +1,32 @@ +package book.book.recommendation.service; + +import java.util.List; +import java.util.Objects; + +public record BookRecommendationResponses(List books) { + + public BookRecommendationResponses { + books = books == null + ? List.of() + : books.stream() + .filter(Objects::nonNull) + .filter(BookRecommendationResponse::isValid) + .toList(); + } + + public boolean isValid() { + return !books.isEmpty(); + } + + public record BookRecommendationResponse(String title, String author) { + public BookRecommendationResponse { + title = title == null ? null : title.trim(); + author = author == null ? null : author.trim(); + } + + public boolean isValid() { + return title != null && !title.isBlank() + && author != null && !author.isBlank(); + } + } +} diff --git a/src/main/java/book/book/recommendation/service/GeminiBookRecommendationProvider.java b/src/main/java/book/book/recommendation/service/GeminiBookRecommendationProvider.java new file mode 100644 index 00000000..c1289360 --- /dev/null +++ b/src/main/java/book/book/recommendation/service/GeminiBookRecommendationProvider.java @@ -0,0 +1,80 @@ +package book.book.recommendation.service; + +import book.book.onboarding.dto.OnboardingKeywordDto; +import book.book.onboarding.dto.OnboardingResultResponse; +import book.book.quiz.dto.external.GeminiRequest; +import book.book.quiz.external.gemini.GeminiSdkClient; +import com.google.genai.types.Content; +import com.google.genai.types.Part; +import com.google.genai.types.Schema; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class GeminiBookRecommendationProvider { + private final GeminiSdkClient geminiSdkClient; + + public BookRecommendationResponses generateRecommendation(OnboardingResultResponse onboarding) { + GeminiRequest request = createRequest(onboarding); + return geminiSdkClient.generateContent(request); + } + + private GeminiRequest createRequest(OnboardingResultResponse onboarding) { + String category = onboarding.category() != null ? onboarding.category().name() : "unspecified"; + String keywords = onboarding.keywords() != null + ? onboarding.keywords().stream().map(OnboardingKeywordDto::name).collect(Collectors.joining(", ")) + : "none provided"; + String favoriteAuthor = onboarding.favoriteAuthor() != null ? onboarding.favoriteAuthor() : "none provided"; + String favoriteBook = onboarding.favoriteBook() != null ? onboarding.favoriteBook() : "none provided"; + String systemPrompt = """ + 당신은 도서 추천 어시스턴트입니다. + 제공된 스키마에 맞는 JSON만 반환하고, 저자가 다양하도록 5권의 강력한 추천을 포함하세요. + 모든 응답은 한국어로 작성하세요. + """; + + String prompt = String.format(""" + 회원 선호 정보: + - 선호 카테고리: %s + - 관심 키워드: %s + - 좋아하는 작가: %s + - 좋아하는 책: %s + """, + category, + keywords, + favoriteAuthor, + favoriteBook); + + Content systemInstruction = Content.fromParts(Part.fromText(systemPrompt)); + + Schema bookSchema = Schema.builder() + .type("object") + .properties(Map.of( + "title", Schema.builder().type("string").build(), + "author", Schema.builder().type("string").build())) + .required(List.of("title", "author")) + .build(); + + Schema schema = Schema.builder() + .type("object") + .properties(Map.of( + "books", Schema.builder() + .type("array") + .items(bookSchema) + .build())) + .required(List.of("books")) + .build(); + + return GeminiRequest.builder() + .prompt(prompt) + .systemInstruction(systemInstruction) + .schema(schema) + .temperature(0.7f) + .responseType(BookRecommendationResponses.class) + .contextForLogging("[도서추천] category: " + category + ", keywords: " + keywords) + .build(); + } +} diff --git a/src/main/java/book/book/recommendation/service/RecommendationService.java b/src/main/java/book/book/recommendation/service/RecommendationService.java new file mode 100644 index 00000000..50f8e555 --- /dev/null +++ b/src/main/java/book/book/recommendation/service/RecommendationService.java @@ -0,0 +1,71 @@ +package book.book.recommendation.service; + +import book.book.book.entity.Book; +import book.book.common.lock.DistributedLock; +import book.book.member.entity.Member; +import book.book.onboarding.dto.OnboardingResultResponse; +import book.book.onboarding.service.OnboardingService; +import book.book.recommendation.entity.MemberBookRecommendation; +import book.book.recommendation.repository.MemberBookRecommendationRepository; +import book.book.search.service.AladinService; +import jakarta.transaction.Transactional; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class RecommendationService { + private final MemberBookRecommendationRepository memberBookRecommendationRepository; + private final OnboardingService onboardingService; + private final GeminiBookRecommendationProvider geminiBookRecommendationProvider; + private final AladinService aladinService; + + @DistributedLock(key = "'recommendation_lock:' + #member.id", waitTime = 0, leaseTime = 30) + @Transactional + public List generateBookRecommendationForMember(Member member) { + OnboardingResultResponse memberPreferences = onboardingService.getMemberPreferences(member.getId()); + BookRecommendationResponses bookRecommendations = geminiBookRecommendationProvider.generateRecommendation(memberPreferences); + List books = bookRecommendations.books() + .stream() + .map(BookRecommendationResponses.BookRecommendationResponse::title) + .map(aladinService::getOrSearchBook) + .flatMap(Optional::stream) + .toList(); + addCollectionWithoutLock(member, books); + return books; + } + + public List getLastRecommendedBooksForMember(Member member) { + return memberBookRecommendationRepository.findLastCollectionIdByMember(member) + .map(lastCollectionId -> memberBookRecommendationRepository + .findByMemberAndCollectionId(member, lastCollectionId) + .stream() + .map(MemberBookRecommendation::getBook) + .toList()) + .orElse(Collections.emptyList()); + } + + @DistributedLock(key = "'recommendation_lock:' + #member.id", waitTime = 0) + @Transactional + public void addCollection(Member member, List books) { + addCollectionWithoutLock(member, books); + } + + private void addCollectionWithoutLock(Member member, List books) { + Long lastCollectionId = memberBookRecommendationRepository.findLastCollectionIdByMember(member) + .orElse(0L); + List recommendations = books.stream() + .map(book -> MemberBookRecommendation.builder() + .book(book) + .member(member) + .collectionId(lastCollectionId + 1) + .build()) + .toList(); + memberBookRecommendationRepository.saveAll(recommendations); + } +} \ No newline at end of file diff --git a/src/main/java/book/book/search/service/AladinService.java b/src/main/java/book/book/search/service/AladinService.java index e8a59135..4028cd2a 100644 --- a/src/main/java/book/book/search/service/AladinService.java +++ b/src/main/java/book/book/search/service/AladinService.java @@ -1,5 +1,9 @@ package book.book.search.service; +import book.book.book.entity.Book; +import book.book.book.repository.BookRepository; +import book.book.book.service.BookSaveService; +import book.book.book.service.BookService; import book.book.common.CustomException; import book.book.search.dto.aladin.AladinItemLookUpResponse; import book.book.search.dto.aladin.AladinSearchResponse; @@ -8,13 +12,17 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.reactive.function.client.WebClient; +import java.util.Optional; + @RequiredArgsConstructor @Service +@Slf4j public class AladinService { private static final int PAGE_SIZE = 20; @@ -45,6 +53,32 @@ public class AladinService { // 기본적으로 JSON은 큰따옴표(")만 허용하지만, 이 설정으로 작은따옴표도 허용 .configure(JsonParser.Feature.ALLOW_SINGLE_QUOTES, true); + private final BookRepository bookRepository; + private final BookSaveService bookSaveService; + private final BookService bookService; + + @Transactional + public Optional getOrSearchBook(String bookTitle) { + Optional existingBook = bookRepository.findByTitle(bookTitle); + if (existingBook.isPresent()) { + return existingBook; + } + + try { + AladinSearchResponse searchResponse = search(bookTitle, 1, 1); + if (searchResponse.getItem().isEmpty()) { + log.error("책 찾을 수 없음: {}", bookTitle); + return Optional.empty(); + } + + Book book = bookService.saveBooksParallel(searchResponse.getItem()).get(0); + return Optional.of(book); + } catch (Exception e) { + log.error("책 검색 중 오류 발생: {}", bookTitle, e); + return Optional.empty(); + } + } + @Transactional public AladinSearchResponse search(String query, Integer start, int pageSize) { try {