diff --git a/src/main/java/com/devpick/domain/content/repository/AiSummaryRepository.java b/src/main/java/com/devpick/domain/content/repository/AiSummaryRepository.java index f73cef9..54fb65d 100644 --- a/src/main/java/com/devpick/domain/content/repository/AiSummaryRepository.java +++ b/src/main/java/com/devpick/domain/content/repository/AiSummaryRepository.java @@ -6,17 +6,25 @@ import software.amazon.awssdk.enhanced.dynamodb.DynamoDbTable; import software.amazon.awssdk.enhanced.dynamodb.Key; import software.amazon.awssdk.enhanced.dynamodb.TableSchema; +import software.amazon.awssdk.enhanced.dynamodb.model.BatchGetItemEnhancedRequest; +import software.amazon.awssdk.enhanced.dynamodb.model.ReadBatch; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.UUID; @Repository public class AiSummaryRepository { private static final String TABLE_NAME = "ai_summaries"; + private final DynamoDbEnhancedClient enhancedClient; private final DynamoDbTable table; public AiSummaryRepository(DynamoDbEnhancedClient enhancedClient) { + this.enhancedClient = enhancedClient; this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(AiSummaryDocument.class)); } @@ -27,4 +35,24 @@ public Optional findByContentIdAndLevel(String contentId, Str .build(); return Optional.ofNullable(table.getItem(key)); } + + /** contentId 목록에 대해 지정 level의 coreSummary를 일괄 조회한다. */ + public Map batchFindCoreSummaries(List contentIds, String level) { + if (contentIds.isEmpty()) return Map.of(); + + ReadBatch.Builder batchBuilder = ReadBatch.builder(AiSummaryDocument.class) + .mappedTableResource(table); + contentIds.forEach(id -> batchBuilder.addGetItem( + Key.builder().partitionValue(id).sortValue(level).build())); + + Map result = new HashMap<>(); + enhancedClient.batchGetItem( + BatchGetItemEnhancedRequest.builder().readBatches(batchBuilder.build()).build() + ).resultsForTable(table).forEach(doc -> { + if (doc.getCoreSummary() != null) { + result.put(UUID.fromString(doc.getContentId()), doc.getCoreSummary()); + } + }); + return result; + } } diff --git a/src/main/java/com/devpick/domain/content/service/AiSummaryService.java b/src/main/java/com/devpick/domain/content/service/AiSummaryService.java index 6411d9c..fdd2242 100644 --- a/src/main/java/com/devpick/domain/content/service/AiSummaryService.java +++ b/src/main/java/com/devpick/domain/content/service/AiSummaryService.java @@ -69,7 +69,7 @@ public AiSummaryResponse getSummary(UUID userId, UUID contentId, String level) { * 백엔드 level 값을 AI 서버 DynamoDB SK로 정규화한다. * BEGINNER→beginner, JUNIOR→junior, MIDDLE→mid, SENIOR→senior */ - static String toAiServerLevel(String level) { + public static String toAiServerLevel(String level) { return switch (level.toUpperCase()) { case "MIDDLE" -> "mid"; default -> level.toLowerCase(); diff --git a/src/main/java/com/devpick/domain/report/dto/HistoryItemResponse.java b/src/main/java/com/devpick/domain/report/dto/HistoryItemResponse.java index 1950c04..d58e6a3 100644 --- a/src/main/java/com/devpick/domain/report/dto/HistoryItemResponse.java +++ b/src/main/java/com/devpick/domain/report/dto/HistoryItemResponse.java @@ -6,6 +6,7 @@ import java.time.Instant; import java.time.ZoneOffset; +import java.util.Map; import java.util.UUID; public record HistoryItemResponse( @@ -24,12 +25,16 @@ public record AnswerInfo(UUID id, String preview) {} public record CommentInfo(UUID id, String preview) {} public static HistoryItemResponse of(History history) { - ContentInfo contentInfo = history.getContent() != null - ? new ContentInfo( - history.getContent().getId(), - history.getContent().getTitle(), - history.getContent().getPreview()) - : null; + return of(history, Map.of()); + } + + public static HistoryItemResponse of(History history, Map summaryMap) { + ContentInfo contentInfo = null; + if (history.getContent() != null) { + UUID contentId = history.getContent().getId(); + String preview = summaryMap.getOrDefault(contentId, history.getContent().getPreview()); + contentInfo = new ContentInfo(contentId, history.getContent().getTitle(), preview); + } PostInfo postInfo = history.getPost() != null ? new PostInfo( diff --git a/src/main/java/com/devpick/domain/report/service/HistoryService.java b/src/main/java/com/devpick/domain/report/service/HistoryService.java index cf96b9d..3271d1d 100644 --- a/src/main/java/com/devpick/domain/report/service/HistoryService.java +++ b/src/main/java/com/devpick/domain/report/service/HistoryService.java @@ -1,15 +1,19 @@ package com.devpick.domain.report.service; +import com.devpick.domain.content.repository.AiSummaryRepository; +import com.devpick.domain.content.service.AiSummaryService; import com.devpick.domain.report.dto.ActivityItemResponse; import com.devpick.domain.report.dto.ActivityPageResponse; import com.devpick.domain.report.dto.HistoryItemResponse; import com.devpick.domain.report.dto.HistoryPageResponse; import com.devpick.domain.report.entity.History; import com.devpick.domain.report.repository.HistoryRepository; +import com.devpick.domain.user.entity.User; import com.devpick.domain.user.repository.UserRepository; import com.devpick.global.common.exception.DevpickException; import com.devpick.global.common.exception.ErrorCode; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -19,20 +23,23 @@ import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.List; +import java.util.Map; import java.util.UUID; +@Slf4j @Service @RequiredArgsConstructor public class HistoryService { private final HistoryRepository historyRepository; private final UserRepository userRepository; + private final AiSummaryRepository aiSummaryRepository; // DP-248/DP-293: 히스토리 조회 - 2단계 쿼리로 FETCH JOIN + 페이징 메모리 이슈 해결 @Transactional(readOnly = true) public HistoryPageResponse getHistory(UUID userId, List actionTypes, OffsetDateTime startDate, OffsetDateTime endDate, Pageable pageable) { - userRepository.findByIdAndIsActiveTrue(userId) + User user = userRepository.findByIdAndIsActiveTrue(userId) .orElseThrow(() -> new DevpickException(ErrorCode.USER_NOT_FOUND)); LocalDateTime start = startDate != null @@ -64,8 +71,9 @@ public HistoryPageResponse getHistory(UUID userId, List actionTypes, // 2단계: 페이지 크기(최대 100개)만큼만 FETCH JOIN으로 연관 엔티티 로딩 List histories = historyRepository.findHistoriesWithAssociationsByIds(idPage.getContent()); + Map summaryMap = buildSummaryMap(histories, user.getLevel().name()); List items = histories.stream() - .map(HistoryItemResponse::of) + .map(h -> HistoryItemResponse.of(h, summaryMap)) .toList(); return new HistoryPageResponse( @@ -80,7 +88,7 @@ public HistoryPageResponse getHistory(UUID userId, List actionTypes, /** 전체 활동 (content_liked 포함) — {@code GET /history/activity} */ @Transactional(readOnly = true) public ActivityPageResponse getActivityHistory(UUID userId, Pageable pageable) { - userRepository.findByIdAndIsActiveTrue(userId) + User user = userRepository.findByIdAndIsActiveTrue(userId) .orElseThrow(() -> new DevpickException(ErrorCode.USER_NOT_FOUND)); Page idPage = historyRepository.findAllHistoryIds(userId, pageable); @@ -90,8 +98,9 @@ public ActivityPageResponse getActivityHistory(UUID userId, Pageable pageable) { } List histories = historyRepository.findHistoriesWithAssociationsByIds(idPage.getContent()); + Map summaryMap = buildSummaryMap(histories, user.getLevel().name()); List items = histories.stream() - .map(h -> ActivityItemResponse.from(HistoryItemResponse.of(h))) + .map(h -> ActivityItemResponse.from(HistoryItemResponse.of(h, summaryMap))) .toList(); return new ActivityPageResponse( @@ -102,4 +111,22 @@ public ActivityPageResponse getActivityHistory(UUID userId, Pageable pageable) { idPage.getTotalPages() ); } + + private Map buildSummaryMap(List histories, String userLevel) { + List contentIds = histories.stream() + .filter(h -> h.getContent() != null) + .map(h -> h.getContent().getId().toString()) + .distinct() + .toList(); + + if (contentIds.isEmpty()) return Map.of(); + + try { + String aiLevel = AiSummaryService.toAiServerLevel(userLevel); + return aiSummaryRepository.batchFindCoreSummaries(contentIds, aiLevel); + } catch (Exception e) { + log.warn("AI 요약 배치 조회 실패 — 원문 미리보기로 fallback: {}", e.getMessage()); + return Map.of(); + } + } } diff --git a/src/test/java/com/devpick/domain/report/service/HistoryServiceTest.java b/src/test/java/com/devpick/domain/report/service/HistoryServiceTest.java index 3c8f975..836b512 100644 --- a/src/test/java/com/devpick/domain/report/service/HistoryServiceTest.java +++ b/src/test/java/com/devpick/domain/report/service/HistoryServiceTest.java @@ -3,10 +3,13 @@ import com.devpick.domain.community.entity.Answer; import com.devpick.domain.community.entity.Post; import com.devpick.domain.content.entity.Content; +import com.devpick.domain.content.repository.AiSummaryRepository; import com.devpick.domain.report.dto.ActivityPageResponse; import com.devpick.domain.report.dto.HistoryPageResponse; import com.devpick.domain.report.entity.History; import com.devpick.domain.report.repository.HistoryRepository; +import com.devpick.domain.user.entity.Job; +import com.devpick.domain.user.entity.Level; import com.devpick.domain.user.entity.User; import com.devpick.domain.user.repository.UserRepository; import com.devpick.global.common.exception.DevpickException; @@ -30,6 +33,7 @@ import java.time.OffsetDateTime; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.stream.Stream; @@ -37,8 +41,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -52,6 +59,9 @@ class HistoryServiceTest { @Mock private UserRepository userRepository; + @Mock + private AiSummaryRepository aiSummaryRepository; + @InjectMocks private HistoryService historyService; @@ -62,9 +72,12 @@ class HistoryServiceTest { @BeforeEach void setUp() { userId = UUID.randomUUID(); - user = User.builder().email("test@devpick.kr").nickname("하영").build(); + user = User.builder().email("test@devpick.kr").nickname("하영") + .job(Job.BACKEND).level(Level.JUNIOR).build(); ReflectionTestUtils.setField(user, "id", userId); pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "createdAt")); + lenient().when(aiSummaryRepository.batchFindCoreSummaries(anyList(), anyString())) + .thenReturn(Map.of()); } // ============================================================ @@ -296,6 +309,95 @@ void getLearningHistory_withActionTypesAndDateRange_usesActionTypesDateRangeQuer verify(historyRepository, never()).findHistoryIdsByActionTypes(any(), any(), any()); } + // ============================================================ + // content preview — AI 요약 매핑 (DP-370) + // ============================================================ + + @Test + @DisplayName("콘텐츠 미리보기 - AI 요약 있으면 coreSummary로 반환") + void getLearningHistory_contentWithSummary_returnsCoreSummary() { + Content content = mock(Content.class); + UUID contentId = UUID.randomUUID(); + given(content.getId()).willReturn(contentId); + given(content.getTitle()).willReturn("Spring Security"); + given(content.getPreview()).willReturn("원문 미리보기"); + + History history = History.builder() + .user(user).actionType("content_opened").content(content).build(); + UUID historyId = UUID.randomUUID(); + ReflectionTestUtils.setField(history, "id", historyId); + + Page idPage = new PageImpl<>(List.of(historyId), pageable, 1); + given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); + given(historyRepository.findHistoryIdsExcludingContentLiked(eq(userId), any(Pageable.class))) + .willReturn(idPage); + given(historyRepository.findHistoriesWithAssociationsByIds(List.of(historyId))) + .willReturn(List.of(history)); + given(aiSummaryRepository.batchFindCoreSummaries(anyList(), anyString())) + .willReturn(Map.of(contentId, "Spring Security는 인증/인가 프레임워크입니다.")); + + HistoryPageResponse response = historyService.getHistory(userId, null, null, null, pageable); + + assertThat(response.items().get(0).content().preview()) + .isEqualTo("Spring Security는 인증/인가 프레임워크입니다."); + } + + @Test + @DisplayName("콘텐츠 미리보기 - AI 요약 없으면 원문 preview fallback") + void getLearningHistory_contentWithoutSummary_fallsBackToPreview() { + Content content = mock(Content.class); + UUID contentId = UUID.randomUUID(); + given(content.getId()).willReturn(contentId); + given(content.getTitle()).willReturn("JPA 기초"); + given(content.getPreview()).willReturn("원문 미리보기 텍스트"); + + History history = History.builder() + .user(user).actionType("content_opened").content(content).build(); + UUID historyId = UUID.randomUUID(); + ReflectionTestUtils.setField(history, "id", historyId); + + Page idPage = new PageImpl<>(List.of(historyId), pageable, 1); + given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); + given(historyRepository.findHistoryIdsExcludingContentLiked(eq(userId), any(Pageable.class))) + .willReturn(idPage); + given(historyRepository.findHistoriesWithAssociationsByIds(List.of(historyId))) + .willReturn(List.of(history)); + given(aiSummaryRepository.batchFindCoreSummaries(anyList(), anyString())) + .willReturn(Map.of()); // 요약 없음 + + HistoryPageResponse response = historyService.getHistory(userId, null, null, null, pageable); + + assertThat(response.items().get(0).content().preview()).isEqualTo("원문 미리보기 텍스트"); + } + + @Test + @DisplayName("콘텐츠 미리보기 - DynamoDB 조회 실패 시 원문 preview fallback") + void getLearningHistory_summaryFetchFails_fallsBackToPreview() { + Content content = mock(Content.class); + UUID contentId = UUID.randomUUID(); + given(content.getId()).willReturn(contentId); + given(content.getTitle()).willReturn("Redis 캐시"); + given(content.getPreview()).willReturn("Redis 관련 원문"); + + History history = History.builder() + .user(user).actionType("content_opened").content(content).build(); + UUID historyId = UUID.randomUUID(); + ReflectionTestUtils.setField(history, "id", historyId); + + Page idPage = new PageImpl<>(List.of(historyId), pageable, 1); + given(userRepository.findByIdAndIsActiveTrue(userId)).willReturn(Optional.of(user)); + given(historyRepository.findHistoryIdsExcludingContentLiked(eq(userId), any(Pageable.class))) + .willReturn(idPage); + given(historyRepository.findHistoriesWithAssociationsByIds(List.of(historyId))) + .willReturn(List.of(history)); + given(aiSummaryRepository.batchFindCoreSummaries(anyList(), anyString())) + .willThrow(new RuntimeException("DynamoDB 연결 실패")); + + HistoryPageResponse response = historyService.getHistory(userId, null, null, null, pageable); + + assertThat(response.items().get(0).content().preview()).isEqualTo("Redis 관련 원문"); + } + // ============================================================ // answer preview — markdown stripping (DP-368) // ============================================================