Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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: 유닛테스트
Expand Down Expand Up @@ -137,7 +130,7 @@ private NaverBlogPostCrawlResult.SaveResult savePost(CrawledData crawledData, Na
}

String bookTitle = bookTitleOptional.get();
Optional<Book> bookOptional = findOrCreateBook(bookTitle);
Optional<Book> bookOptional = aladinService.getOrSearchBook(bookTitle);
if (bookOptional.isEmpty()) {
log.warn("검색 결과가 없는 책 {}/{}", blogPost.getBlogId(), blogPost.getPostId());
return NaverBlogPostCrawlResult.SaveResult.NO_BOOK;
Expand All @@ -159,27 +152,6 @@ private NaverBlogPostCrawlResult.SaveResult savePost(CrawledData crawledData, Na
return NaverBlogPostCrawlResult.SaveResult.SUCCESS;
}

private Optional<Book> findOrCreateBook(String bookTitle) {
Optional<Book> 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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<BookRecommendationResponse> refreshBooksForMember(@PathVariable Long memberId) {
Member member = memberRepository.findByIdOrElseThrow(memberId);
List<Book> books = recommendationService.generateBookRecommendationForMember(member);
return books.stream().map(BookRecommendationResponse::from).toList();
}

@GetMapping("/members/{memberId}/books")
public List<BookRecommendationResponse> getBooksForMember(@PathVariable Long memberId) {
Member member = memberRepository.findByIdOrElseThrow(memberId);
List<Book> books = recommendationService.getLastRecommendedBooksForMember(member);
return books.stream().map(BookRecommendationResponse::from).toList();
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<MemberBookRecommendation, Long> {
@Query(value = "SELECT MAX(mbr.collectionId) FROM MemberBookRecommendation mbr WHERE mbr.member = :member")
Optional<Long> findLastCollectionIdByMember(Member member);

List<MemberBookRecommendation> findByMemberAndCollectionId(Member member, Long collectionId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package book.book.recommendation.service;

import java.util.List;
import java.util.Objects;

public record BookRecommendationResponses(List<BookRecommendationResponse> 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();
}
}
}
Original file line number Diff line number Diff line change
@@ -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<BookRecommendationResponses> request = createRequest(onboarding);
return geminiSdkClient.generateContent(request);
}

private GeminiRequest<BookRecommendationResponses> createRequest(OnboardingResultResponse onboarding) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

세인님 코드보니까 GeminiQuizPromptProvider랑 GeminiRequestFactory 합쳐도 되겠다라는 생각이 드네요 감사합니다!

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.<BookRecommendationResponses>builder()
.prompt(prompt)
.systemInstruction(systemInstruction)
.schema(schema)
.temperature(0.7f)
.responseType(BookRecommendationResponses.class)
.contextForLogging("[도서추천] category: " + category + ", keywords: " + keywords)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -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<Book> generateBookRecommendationForMember(Member member) {
OnboardingResultResponse memberPreferences = onboardingService.getMemberPreferences(member.getId());
BookRecommendationResponses bookRecommendations = geminiBookRecommendationProvider.generateRecommendation(memberPreferences);
List<Book> books = bookRecommendations.books()
.stream()
.map(BookRecommendationResponses.BookRecommendationResponse::title)
.map(aladinService::getOrSearchBook)
.flatMap(Optional::stream)
.toList();
addCollectionWithoutLock(member, books);
return books;
}

public List<Book> 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<Book> books) {
addCollectionWithoutLock(member, books);
}

private void addCollectionWithoutLock(Member member, List<Book> books) {
Long lastCollectionId = memberBookRecommendationRepository.findLastCollectionIdByMember(member)
.orElse(0L);
List<MemberBookRecommendation> recommendations = books.stream()
.map(book -> MemberBookRecommendation.builder()
.book(book)
.member(member)
.collectionId(lastCollectionId + 1)
.build())
.toList();
memberBookRecommendationRepository.saveAll(recommendations);
}
}
Loading
Loading