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 @@ -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<AiSummaryDocument> table;

public AiSummaryRepository(DynamoDbEnhancedClient enhancedClient) {
this.enhancedClient = enhancedClient;
this.table = enhancedClient.table(TABLE_NAME, TableSchema.fromBean(AiSummaryDocument.class));
}

Expand All @@ -27,4 +35,24 @@ public Optional<AiSummaryDocument> findByContentIdAndLevel(String contentId, Str
.build();
return Optional.ofNullable(table.getItem(key));
}

/** contentId 목록에 대해 지정 level의 coreSummary를 일괄 조회한다. */
public Map<UUID, String> batchFindCoreSummaries(List<String> contentIds, String level) {
if (contentIds.isEmpty()) return Map.of();

ReadBatch.Builder<AiSummaryDocument> batchBuilder = ReadBatch.builder(AiSummaryDocument.class)
.mappedTableResource(table);
contentIds.forEach(id -> batchBuilder.addGetItem(
Key.builder().partitionValue(id).sortValue(level).build()));

Map<UUID, String> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.UUID;

public record HistoryItemResponse(
Expand All @@ -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<UUID, String> 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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<String> 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
Expand Down Expand Up @@ -64,8 +71,9 @@ public HistoryPageResponse getHistory(UUID userId, List<String> actionTypes,
// 2단계: 페이지 크기(최대 100개)만큼만 FETCH JOIN으로 연관 엔티티 로딩
List<History> histories = historyRepository.findHistoriesWithAssociationsByIds(idPage.getContent());

Map<UUID, String> summaryMap = buildSummaryMap(histories, user.getLevel().name());
List<HistoryItemResponse> items = histories.stream()
.map(HistoryItemResponse::of)
.map(h -> HistoryItemResponse.of(h, summaryMap))
.toList();

return new HistoryPageResponse(
Expand All @@ -80,7 +88,7 @@ public HistoryPageResponse getHistory(UUID userId, List<String> 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<UUID> idPage = historyRepository.findAllHistoryIds(userId, pageable);
Expand All @@ -90,8 +98,9 @@ public ActivityPageResponse getActivityHistory(UUID userId, Pageable pageable) {
}

List<History> histories = historyRepository.findHistoriesWithAssociationsByIds(idPage.getContent());
Map<UUID, String> summaryMap = buildSummaryMap(histories, user.getLevel().name());
List<ActivityItemResponse> items = histories.stream()
.map(h -> ActivityItemResponse.from(HistoryItemResponse.of(h)))
.map(h -> ActivityItemResponse.from(HistoryItemResponse.of(h, summaryMap)))
.toList();

return new ActivityPageResponse(
Expand All @@ -102,4 +111,22 @@ public ActivityPageResponse getActivityHistory(UUID userId, Pageable pageable) {
idPage.getTotalPages()
);
}

private Map<UUID, String> buildSummaryMap(List<History> histories, String userLevel) {
List<String> 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();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -30,15 +33,19 @@

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;

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;
Expand All @@ -52,6 +59,9 @@ class HistoryServiceTest {
@Mock
private UserRepository userRepository;

@Mock
private AiSummaryRepository aiSummaryRepository;

@InjectMocks
private HistoryService historyService;

Expand All @@ -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());
}

// ============================================================
Expand Down Expand Up @@ -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<UUID> 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<UUID> 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<UUID> 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)
// ============================================================
Expand Down
Loading