Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
Expand All @@ -16,6 +18,13 @@ public class ReadPostLookupService {

private final ReadPostRepository readPostRepository;

public Set<Long> getRecentReadPostIds(Long userId, int limit) {
return readPostRepository.findRecentReadPostsByUserIdWithMinDuration(userId, PageRequest.of(0, limit))
.stream()
.map(readPost -> readPost.getPost().getId())
.collect(Collectors.toSet());
}

public List<ReadPostLookupItem> getRecentReadPostActivities(Long userId, int limit) {
return readPostRepository.findRecentReadPostsByUserIdWithMinDuration(userId, PageRequest.of(0, limit))
.stream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import com.techfork.domain.recommendation.config.RecommendationProperties;
import com.techfork.domain.recommendation.service.RecommendationService;
import com.techfork.useraccount.application.query.lookup.UserLookupService;
import com.techfork.useraccount.domain.User;
import com.techfork.useraccount.infrastructure.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
Expand All @@ -17,7 +17,7 @@
@RequiredArgsConstructor
public class RecommendationScheduler {

private final UserRepository userRepository;
private final UserLookupService userLookupService;
private final RecommendationService recommendationService;
private final RecommendationProperties properties;

Expand All @@ -32,7 +32,7 @@ public void generateDailyRecommendations() {
log.info("활성 사용자 대상으로 게시글 추천 시작");

LocalDateTime since = LocalDateTime.now().minusHours(properties.getActiveUserHours());
List<User> activeUsers = userRepository.findActiveUsersSince(since);
List<User> activeUsers = userLookupService.getActiveUsersSince(since);

log.info("{} 명의 활성 사용자를 찾았습니다. ({} 시간 이내)", activeUsers.size(), properties.getActiveUserHours());

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,37 +5,35 @@
import co.elastic.clients.elasticsearch._types.query_dsl.Query;
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.elasticsearch.core.search.Hit;
import com.techfork.activity.readpost.infrastructure.ReadPostRepository;
import com.techfork.global.elasticsearch.query.VectorQueryBuilder;
import com.techfork.post.domain.projection.PostDocument;
import com.techfork.post.domain.Post;
import com.techfork.post.infrastructure.PostRepository;
import com.techfork.activity.readpost.application.query.lookup.ReadPostLookupService;
import com.techfork.domain.recommendation.config.RecommendationProperties;
import com.techfork.domain.recommendation.entity.RecommendedPost;
import com.techfork.domain.recommendation.entity.RecommendationHistory;
import com.techfork.domain.recommendation.repository.RecommendedPostRepository;
import com.techfork.domain.recommendation.entity.RecommendedPost;
import com.techfork.domain.recommendation.repository.RecommendationHistoryRepository;
import com.techfork.domain.recommendation.repository.RecommendedPostRepository;
import com.techfork.domain.recommendation.service.MmrService.MmrCandidate;
import com.techfork.domain.recommendation.service.MmrService.MmrResult;
import com.techfork.personalization.infrastructure.PersonalizationProfileDocument;
import com.techfork.useraccount.domain.User;
import com.techfork.personalization.infrastructure.PersonalizationProfileDocumentRepository;
import com.techfork.global.elasticsearch.query.VectorQueryBuilder;
import com.techfork.global.util.RrfScorer;
import com.techfork.global.util.TimeDecayStrategy;
import com.techfork.global.util.VectorUtil;
import com.techfork.personalization.application.query.lookup.PersonalizationProfileLookupItem;
import com.techfork.personalization.application.query.lookup.PersonalizationProfileLookupService;
import com.techfork.post.application.query.lookup.PostLookupService;
import com.techfork.post.domain.Post;
import com.techfork.post.domain.projection.PostDocument;
import com.techfork.useraccount.domain.User;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Primary;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.io.IOException;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.stream.Collectors;

/**
* MMR 알고리즘 기반 추천 전략 구현
Expand All @@ -48,11 +46,11 @@
public class LlmRecommendationService implements RecommendationService {

private final ElasticsearchClient elasticsearchClient;
private final PersonalizationProfileDocumentRepository personalizationProfileDocumentRepository;
private final PersonalizationProfileLookupService personalizationProfileLookupService;
private final RecommendedPostRepository recommendedPostRepository;
private final RecommendationHistoryRepository recommendationHistoryRepository;
private final ReadPostRepository readPostRepository;
private final PostRepository postRepository;
private final ReadPostLookupService readPostLookupService;
private final PostLookupService postLookupService;
private final MmrService mmrService;
private final TimeDecayStrategy timeDecayStrategy;
private final RecommendationProperties properties;
Expand All @@ -64,21 +62,22 @@ public class LlmRecommendationService implements RecommendationService {
private static final String TITLE_EMBEDDING_FIELD = "titleEmbedding";
private static final String SUMMARY_EMBEDDING_FIELD = "summaryEmbedding";
private static final String CONTENT_CHUNKS_EMBEDDING_FIELD = "contentChunks.embedding";
private static final int RECENT_READ_POST_LIMIT = 1000;

@Override
public int generateRecommendationsForUser(User user) {
Optional<PersonalizationProfileDocument> personalizationProfileOpt =
personalizationProfileDocumentRepository.findByUserId(user.getId());
if (personalizationProfileOpt.isEmpty() || personalizationProfileOpt.get().getProfileVector() == null) {
Optional<PersonalizationProfileLookupItem> personalizationProfileOpt =
personalizationProfileLookupService.findByUserId(user.getId());
if (personalizationProfileOpt.isEmpty() || personalizationProfileOpt.get().profileVector() == null) {
log.warn("사용자 {}의 개인화 프로필 또는 벡터를 찾을 수 없음. 추천 생성 스킵.", user.getId());
return 0;
}

PersonalizationProfileDocument personalizationProfile = personalizationProfileOpt.get();
PersonalizationProfileLookupItem personalizationProfile = personalizationProfileOpt.get();
return generateRecommendationsForUser(
user,
personalizationProfile.getProfileVector(),
personalizationProfile.getKeyKeywords()
personalizationProfile.profileVector(),
personalizationProfile.keyKeywords()
);
}

Expand Down Expand Up @@ -118,7 +117,7 @@ public int generateRecommendationsForUser(User user, float[] personalizationProf
// 5. 새 추천 저장
List<RecommendedPost> recommendations = new ArrayList<>();
for (MmrResult result : mmrResults) {
Post post = postRepository.getReferenceById(result.getPostId());
Post post = postLookupService.getPostReference(result.getPostId());
recommendations.add(RecommendedPost.create(
user, post, result.getSimilarityScore(), result.getMmrScore(), result.getRank()
));
Expand All @@ -138,10 +137,7 @@ private List<MmrCandidate> searchCandidates(
List<String> keyKeywords,
User user
) throws IOException {
Set<Long> readPostIds = readPostRepository.findRecentReadPostsByUserIdWithMinDuration(user.getId(), PageRequest.of(0, 1000))
.stream()
.map(readPost -> readPost.getPost().getId())
.collect(Collectors.toSet());
Set<Long> readPostIds = readPostLookupService.getRecentReadPostIds(user.getId(), RECENT_READ_POST_LIMIT);

RecommendationProperties.EmbeddingWeights weights = properties.getEmbeddingWeights();
Query filterQuery = vectorQueryBuilder.createExcludeFilter(readPostIds);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.techfork.domain.recommendation.service;

import com.techfork.useraccount.application.query.lookup.UserLookupService;
import com.techfork.useraccount.domain.User;
import com.techfork.useraccount.infrastructure.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
Expand All @@ -14,10 +14,10 @@
public class RecommendationCommandService {

private final RecommendationService recommendationService;
private final UserRepository userRepository;
private final UserLookupService userLookupService;

public void regenerateRecommendations(Long userId) {
User user = userRepository.getReferenceById(userId);
User user = userLookupService.getUserReference(userId);
int generatedCount = recommendationService.generateRecommendationsForUser(user);
log.info("사용자 {} 추천 즉시 재생성 완료: {} 개", userId, generatedCount);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
package com.techfork.domain.recommendation.service;

import com.techfork.activity.bookmark.infrastructure.BookmarkRepository;
import com.techfork.activity.bookmark.application.query.lookup.BookmarkLookupService;
import com.techfork.domain.recommendation.converter.RecommendationConverter;
import com.techfork.domain.recommendation.dto.RecommendationListResponse;
import com.techfork.domain.recommendation.dto.RecommendedPostDto;
import com.techfork.domain.recommendation.entity.RecommendedPost;
import com.techfork.domain.recommendation.repository.RecommendedPostRepository;
import com.techfork.useraccount.application.query.lookup.UserLookupService;
import com.techfork.useraccount.domain.User;
import com.techfork.useraccount.infrastructure.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Set;

@Slf4j
@Service
Expand All @@ -22,12 +23,12 @@
public class RecommendationQueryService {

private final RecommendedPostRepository recommendedPostRepository;
private final UserRepository userRepository;
private final UserLookupService userLookupService;
private final RecommendationConverter recommendationConverter;
private final BookmarkRepository bookmarkRepository;
private final BookmarkLookupService bookmarkLookupService;

public RecommendationListResponse getRecommendations(Long userId) {
User user = userRepository.getReferenceById(userId);
User user = userLookupService.getUserReference(userId);
List<RecommendedPost> recommendedPosts = recommendedPostRepository.findByUserOrderByRankAsc(user);
log.info("사용자 {} 추천 목록 조회: {} 개", userId, recommendedPosts.size());

Expand All @@ -45,7 +46,7 @@ private RecommendationListResponse attachBookmarkStatus(RecommendationListRespon
List<Long> postIds = response.recommendations().stream()
.map(RecommendedPostDto::postId)
.toList();
List<Long> bookmarkedPostIds = bookmarkRepository.findBookmarkedPostIds(userId, postIds);
Set<Long> bookmarkedPostIds = bookmarkLookupService.getBookmarkedPostIds(userId, postIds);

List<RecommendedPostDto> updatedRecommendations = response.recommendations().stream()
.map(dto -> dto.withBookmarkStatus(bookmarkedPostIds.contains(dto.postId())))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.techfork.personalization.application.query.lookup;

import java.util.List;

public record PersonalizationProfileLookupItem(
float[] profileVector,
List<String> keyKeywords
) {
public PersonalizationProfileLookupItem {
profileVector = profileVector == null ? null : profileVector.clone();
keyKeywords = keyKeywords == null ? List.of() : List.copyOf(keyKeywords);
}

@Override
public float[] profileVector() {
return profileVector == null ? null : profileVector.clone();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.techfork.personalization.application.query.lookup;

import com.techfork.personalization.infrastructure.PersonalizationProfileDocumentRepository;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PersonalizationProfileLookupService {

private final PersonalizationProfileDocumentRepository personalizationProfileDocumentRepository;

public Optional<PersonalizationProfileLookupItem> findByUserId(Long userId) {
return personalizationProfileDocumentRepository.findByUserId(userId)
.map(profile -> new PersonalizationProfileLookupItem(
profile.getProfileVector(),
profile.getKeyKeywords()
));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ public class PostLookupService {

private final PostRepository postRepository;

public Post getPostReference(Long postId) {
return postRepository.getReferenceById(postId);
}

public Post getPostOrThrow(Long postId) {
return postRepository.findById(postId)
.orElseThrow(() -> new GeneralException(PostErrorCode.POST_NOT_FOUND));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,21 @@ public class UserLookupService {

private final UserRepository userRepository;

public User getUserReference(Long userId) {
return userRepository.getReferenceById(userId);
}

public User getUserOrThrow(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new GeneralException(UserErrorCode.USER_NOT_FOUND));
}

public List<User> getActiveUsersSince(LocalDateTime since) {
return userRepository.findActiveUsersSince(since);
}

public List<Long> getActiveUserIdsSince(LocalDateTime since) {
return userRepository.findActiveUsersSince(since)
return getActiveUsersSince(since)
.stream()
.map(User::getId)
.toList();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import org.springframework.data.domain.PageRequest;

import java.util.List;
import java.util.Set;

import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.BDDMockito.given;
Expand All @@ -29,6 +30,36 @@ class ReadPostLookupServiceTest {
@InjectMocks
private ReadPostLookupService readPostLookupService;

@Nested
@DisplayName("최근 읽은 게시글 ID 조회")
class GetRecentReadPostIds {

@Test
@DisplayName("조회된 읽기 기록의 게시글 ID를 중복 없이 반환한다")
void recentReadPostsExist_ReturnsDistinctPostIds() {
Long userId = 1L;
int limit = 1000;
Post firstPost = mock(Post.class);
Post secondPost = mock(Post.class);
ReadPost firstReadPost = mock(ReadPost.class);
ReadPost duplicateReadPost = mock(ReadPost.class);
ReadPost secondReadPost = mock(ReadPost.class);
given(firstReadPost.getPost()).willReturn(firstPost);
given(duplicateReadPost.getPost()).willReturn(firstPost);
given(secondReadPost.getPost()).willReturn(secondPost);
given(firstPost.getId()).willReturn(10L);
given(secondPost.getId()).willReturn(20L);
given(readPostRepository.findRecentReadPostsByUserIdWithMinDuration(userId, PageRequest.of(0, limit)))
.willReturn(List.of(firstReadPost, duplicateReadPost, secondReadPost));

Set<Long> result = readPostLookupService.getRecentReadPostIds(userId, limit);

assertThat(result).containsExactlyInAnyOrder(10L, 20L);
verify(readPostRepository)
.findRecentReadPostsByUserIdWithMinDuration(userId, PageRequest.of(0, limit));
}
}

@Nested
@DisplayName("최근 읽은 게시글 활동 신호 조회")
class GetRecentReadPostActivities {
Expand Down
Loading