diff --git a/src/main/java/com/ticle/server/post/dto/GeminiRestTemplateConfig.java b/src/main/java/com/ticle/server/global/config/GeminiRestTemplateConfig.java similarity index 94% rename from src/main/java/com/ticle/server/post/dto/GeminiRestTemplateConfig.java rename to src/main/java/com/ticle/server/global/config/GeminiRestTemplateConfig.java index 75ec2d4..011184b 100644 --- a/src/main/java/com/ticle/server/post/dto/GeminiRestTemplateConfig.java +++ b/src/main/java/com/ticle/server/global/config/GeminiRestTemplateConfig.java @@ -1,4 +1,4 @@ -package com.ticle.server.post.dto; +package com.ticle.server.global.config; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/ticle/server/home/controller/HomeController.java b/src/main/java/com/ticle/server/home/controller/HomeController.java index 4f7d113..42884cf 100644 --- a/src/main/java/com/ticle/server/home/controller/HomeController.java +++ b/src/main/java/com/ticle/server/home/controller/HomeController.java @@ -2,6 +2,7 @@ import com.ticle.server.global.dto.ResponseTemplate; import com.ticle.server.home.dto.request.SubscriptionRequest; +import com.ticle.server.home.dto.response.HomeResponse; import com.ticle.server.home.service.HomeService; import com.ticle.server.user.service.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; @@ -15,6 +16,9 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.GetMapping; + +import java.util.List; import static com.ticle.server.global.dto.ResponseTemplate.EMPTY_RESPONSE; @@ -38,4 +42,15 @@ public ResponseEntity> uploadSubscription( .status(HttpStatus.OK) .body(EMPTY_RESPONSE); } + + @Operation(summary = "홈화면 정보 가져오기", description = "TOP 3와 3개의 소주제를 가져옵니다. (각각 3개의 아티클 포함)") + @GetMapping("") + public ResponseEntity> getTopicsAndPosts() { + + List responseList = homeService.generateHomeInfo(); + + return ResponseEntity + .status(HttpStatus.OK) + .body(ResponseTemplate.from(responseList)); + } } \ No newline at end of file diff --git a/src/main/java/com/ticle/server/home/dto/response/HomeResonse.java b/src/main/java/com/ticle/server/home/dto/response/HomeResonse.java deleted file mode 100644 index 5eb401c..0000000 --- a/src/main/java/com/ticle/server/home/dto/response/HomeResonse.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.ticle.server.home.dto.response; - -public class HomeResonse { -} diff --git a/src/main/java/com/ticle/server/home/dto/response/HomeResponse.java b/src/main/java/com/ticle/server/home/dto/response/HomeResponse.java new file mode 100644 index 0000000..ed8835a --- /dev/null +++ b/src/main/java/com/ticle/server/home/dto/response/HomeResponse.java @@ -0,0 +1,18 @@ +package com.ticle.server.home.dto.response; + +import lombok.Builder; + +import java.util.List; + +@Builder +public record HomeResponse( + String topic, + List responseList +) { + public static HomeResponse of(String topic, List responseList) { + return HomeResponse.builder() + .topic(topic) + .responseList(responseList) + .build(); + } +} diff --git a/src/main/java/com/ticle/server/home/dto/response/PostSetsResponse.java b/src/main/java/com/ticle/server/home/dto/response/PostSetsResponse.java new file mode 100644 index 0000000..efc7ccf --- /dev/null +++ b/src/main/java/com/ticle/server/home/dto/response/PostSetsResponse.java @@ -0,0 +1,24 @@ +package com.ticle.server.home.dto.response; + +import com.ticle.server.post.domain.Post; +import com.ticle.server.user.domain.type.Category; + +import java.time.LocalDate; + +public record PostSetsResponse( + String title, + String imageUrl, + Category category, + String author, + LocalDate createdDate +) { + public static PostSetsResponse from(Post post) { + return new PostSetsResponse( + post.getTitle(), + post.getImage().getImageUrl(), + post.getCategory(), + post.getAuthor(), + post.getCreatedDate() + ); + } +} diff --git a/src/main/java/com/ticle/server/home/service/HomeService.java b/src/main/java/com/ticle/server/home/service/HomeService.java index 9a6112e..8865142 100644 --- a/src/main/java/com/ticle/server/home/service/HomeService.java +++ b/src/main/java/com/ticle/server/home/service/HomeService.java @@ -2,7 +2,10 @@ import com.ticle.server.home.domain.Subscription; import com.ticle.server.home.dto.request.SubscriptionRequest; +import com.ticle.server.home.dto.response.HomeResponse; +import com.ticle.server.home.dto.response.PostSetsResponse; import com.ticle.server.home.repository.SubscriptionRepository; +import com.ticle.server.post.repository.PostRepository; import com.ticle.server.user.domain.User; import com.ticle.server.user.exception.UserNotFoundException; import com.ticle.server.user.repository.UserRepository; @@ -12,6 +15,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; +import java.util.List; + import static com.ticle.server.user.exception.errorcode.UserErrorCode.USER_NOT_FOUND; @Slf4j @@ -22,6 +28,8 @@ public class HomeService { private final UserRepository userRepository; private final SubscriptionRepository subscriptionRepository; + private final PostRepository postRepository; + private final PostTopicCache postTopicCache; @Transactional public void uploadSubscription(SubscriptionRequest request, CustomUserDetails userDetails) { @@ -31,4 +39,28 @@ public void uploadSubscription(SubscriptionRequest request, CustomUserDetails us Subscription subscription = request.toSubscription(user); subscriptionRepository.save(subscription); } + + public List generateHomeInfo() { + List responseList = new ArrayList<>(); + + // 이번주 TOP 3 + responseList.add(findTop3Posts()); + // 랜덤 3개의 주제와 포스트 + responseList.addAll(find3RandomTopicAndPosts()); + + return responseList; + } + + private HomeResponse findTop3Posts() { + List topPosts = postRepository.findTop3ByOrderByScrapCountDesc(); + return HomeResponse.of("이번주 TOP 3", topPosts); + } + + + private List find3RandomTopicAndPosts() { + return postTopicCache.getRandomPosts(3) + .entrySet().stream() + .map(entry -> HomeResponse.of(entry.getKey(), entry.getValue())) + .toList(); + } } diff --git a/src/main/java/com/ticle/server/home/service/PostTopicCache.java b/src/main/java/com/ticle/server/home/service/PostTopicCache.java new file mode 100644 index 0000000..e3b6ece --- /dev/null +++ b/src/main/java/com/ticle/server/home/service/PostTopicCache.java @@ -0,0 +1,44 @@ +package com.ticle.server.home.service; + +import com.ticle.server.home.dto.response.PostSetsResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +@RequiredArgsConstructor +@Component +public class PostTopicCache { + + private static final Map> postTopicCache = new ConcurrentHashMap<>(); + + public void saveToCache(String commonTitle, List posts) { + postTopicCache.put(commonTitle, posts); + } + + public List getPostsFromCache(String commonTitle) { + return postTopicCache.get(commonTitle); + } + + public void clearCache() { + postTopicCache.clear(); + } + + public Map> getRandomPosts(int count) { + List>> allEntries = new ArrayList<>(postTopicCache.entrySet()); + Collections.shuffle(allEntries); + + Map> result = new HashMap<>(); + + for (int i = 0; i < count; i++) { + Map.Entry> entry = allEntries.get(i); + + int resultSize = Math.min(count, entry.getValue().size()); + + result.put(entry.getKey(), new ArrayList<>(entry.getValue().subList(0, resultSize))); + } + + return result; + } +} \ No newline at end of file diff --git a/src/main/java/com/ticle/server/home/service/SchedulingService.java b/src/main/java/com/ticle/server/home/service/SchedulingService.java index 89b7eab..6ed03fc 100644 --- a/src/main/java/com/ticle/server/home/service/SchedulingService.java +++ b/src/main/java/com/ticle/server/home/service/SchedulingService.java @@ -2,19 +2,29 @@ import com.ticle.server.home.domain.Subscription; import com.ticle.server.home.domain.type.Day; +import com.ticle.server.home.dto.response.PostSetsResponse; import com.ticle.server.home.repository.SubscriptionRepository; import com.ticle.server.post.domain.Post; +import com.ticle.server.post.dto.GeminiRequest; +import com.ticle.server.post.dto.GeminiResponse; import com.ticle.server.post.exception.PostNotFoundException; import com.ticle.server.post.repository.PostRepository; import com.ticle.server.user.service.EmailService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; import java.time.LocalDate; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import java.util.concurrent.ThreadLocalRandom; import static com.ticle.server.post.exception.errorcode.PostErrorCode.POST_NOT_FOUND; @@ -22,11 +32,21 @@ @Service @Transactional(readOnly = true) @RequiredArgsConstructor -public class SchedulingService { +public class SchedulingService implements ApplicationRunner { private final SubscriptionRepository subscriptionRepository; private final PostRepository postRepository; private final EmailService emailService; + private final RestTemplate restTemplate; + private final PostTopicCache postTopicCache; + + @Value("${gemini.api.url}") + private String apiUrl; + + @Value("${gemini.api.key2}") + private String geminiApiKey; + + private static final int POST_COUNT = 10; @Scheduled(cron = "0 0 0 * * *") public void sendWeeklyPosts() { @@ -41,4 +61,67 @@ public void sendWeeklyPosts() { emailService.sendEmail(subscription.getUser().getEmail(), topPost); } } + + @Override + public void run(ApplicationArguments args) { + generateCommonTitleMultipleTimes(); + } + + @Scheduled(cron = "0 0 0 * * *") + public void generateCommonTitleMultipleTimes() { + postTopicCache.clearCache(); + + int count = (int) postRepository.count(); + + for (int i = 0; i < POST_COUNT; i++) { + saveCommonTitleAndPostsToCache(count); + } + } + + private void saveCommonTitleAndPostsToCache(int count) { + Set selectedPostIds = getRandomIndices(count); + + // 선택된 post_id에 해당하는 Post 객체 가져오기 + List selectedPosts = postRepository.findSelectedPostInfoByIds(selectedPostIds); + + // AI에게 3개의 Post 제목을 보내서 공통된 제목 추천받기 + String commonTitle = generateCommonTitle(selectedPosts); + + // commonTitle과 selectedPosts를 PostTopicCache에 저장 + postTopicCache.saveToCache(commonTitle, selectedPosts); + log.info("post: {}", postTopicCache.getPostsFromCache(commonTitle)); + } + + private String generateCommonTitle(List selectedPosts) { + // Gemini에 요청 전송 + String requestUrl = apiUrl + "?key=" + geminiApiKey; + + String prompt = selectedPosts.stream() + .map(PostSetsResponse::title) + .toList() + " 다음은 3개의 기사 제목 리스트야. 해당 제목들에 대한 공통제목을 20자 이내로 1개만 추천해줘 " + + "예: commonTitle = [개발자를 위한 성장 가이드]"; + + log.info("prompt: {}", prompt); + + GeminiRequest request = new GeminiRequest(prompt); + GeminiResponse response = restTemplate.postForObject(requestUrl, request, GeminiResponse.class); + + log.info("response: {}", response); + + return response.getCommonTitle(); + } + + private Set getRandomIndices(int count) { + // 3개의 랜덤한 post_id 선택하기 + Set selectedPostIds = new HashSet<>(); + ThreadLocalRandom random = ThreadLocalRandom.current(); + + while (selectedPostIds.size() < 3) { + long randomPostId = random.nextInt(count) + 1L; + selectedPostIds.add(randomPostId); + } + + log.info("selectedPostIds: {}", selectedPostIds); + return selectedPostIds; + } } \ No newline at end of file diff --git a/src/main/java/com/ticle/server/post/dto/GeminiResponse.java b/src/main/java/com/ticle/server/post/dto/GeminiResponse.java index adbc8ae..3bb4bd4 100644 --- a/src/main/java/com/ticle/server/post/dto/GeminiResponse.java +++ b/src/main/java/com/ticle/server/post/dto/GeminiResponse.java @@ -2,6 +2,7 @@ import com.ticle.server.post.repository.PostRepository; import lombok.*; +import org.springframework.util.CollectionUtils; import java.util.*; @@ -84,6 +85,7 @@ public List> formatRecommendPost() { // quiz 생성 데이터 format public List formatQuiz(String postTitle) { List quizzes = new ArrayList<>(); + if (candidates != null) { System.out.println("Candidates found: " + candidates.size()); int quizNo = 1; @@ -122,8 +124,24 @@ public List formatQuiz(String postTitle) { } } } + System.out.println("Formatted quizzes: " + quizzes); return quizzes; } + public String getCommonTitle() { + String text = ""; + + if (candidates != null) { + for (Candidate candidate : candidates) { + Content content = candidate.getContent(); + + if (content != null && !CollectionUtils.isEmpty(content.getParts())) { + text = content.getParts().get(0).getText(); + } + } + } + + return text; + } } \ No newline at end of file diff --git a/src/main/java/com/ticle/server/post/repository/PostRepository.java b/src/main/java/com/ticle/server/post/repository/PostRepository.java index 876ebb7..af2b615 100644 --- a/src/main/java/com/ticle/server/post/repository/PostRepository.java +++ b/src/main/java/com/ticle/server/post/repository/PostRepository.java @@ -1,5 +1,6 @@ package com.ticle.server.post.repository; +import com.ticle.server.home.dto.response.PostSetsResponse; import com.ticle.server.post.domain.Post; import com.ticle.server.post.dto.PostIdTitleDto; import com.ticle.server.user.domain.type.Category; @@ -11,11 +12,10 @@ import java.util.List; import java.util.Optional; +import java.util.Set; public interface PostRepository extends JpaRepository { - Post findByPostId(Long postId); - Page findByCategory(Category category, Pageable pageable); @Query("SELECT new com.ticle.server.post.dto.PostIdTitleDto(p.postId, p.title) FROM Post p") @@ -28,6 +28,16 @@ public interface PostRepository extends JpaRepository { "ORDER BY p.createdDate DESC LIMIT 1") Optional findTopPostByCategory(@Param("category") Category category); + @Query("SELECT new com.ticle.server.home.dto.response.PostSetsResponse(p.title, p.image.imageUrl, p.category, p.author, p.createdDate) " + + "FROM Post p " + + "WHERE p.postId IN (:postIds)") + List findSelectedPostInfoByIds(@Param("postIds") Set postIds); + + @Query("SELECT new com.ticle.server.home.dto.response.PostSetsResponse(p.title, p.image.imageUrl, p.category, p.author, p.createdDate) " + + "FROM Post p " + + "ORDER BY p.scrapCount DESC LIMIT 3") + List findTop3ByOrderByScrapCountDesc(); + @Query("SELECT p FROM Post p WHERE " + "(:keyword IS NULL OR p.title LIKE %:keyword%) AND " + "(:category IS NULL OR p.category = :category)")