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 @@ -2,6 +2,7 @@

import com.techfork.domain.activity.dto.BookmarkListResponse;
import com.techfork.domain.activity.dto.BookmarkRequest;
import com.techfork.domain.activity.dto.ReadPostListResponse;
import com.techfork.domain.activity.dto.ReadPostRequest;
import com.techfork.domain.activity.dto.SearchHistoryRequest;
import com.techfork.domain.activity.service.ActivityCommandService;
Expand All @@ -28,7 +29,23 @@ public class ActivityController {

private final ActivityCommandService activityCommandService;
private final ActivityQueryService activityQueryService;


@Operation(
summary = "읽은 게시글 목록 조회",
description = "사용자가 읽은 게시글 목록을 조회합니다. 최근 읽은 순서로 정렬됩니다."
)
@GetMapping("/read-posts")
public ResponseEntity<BaseResponse<ReadPostListResponse>> getReadPosts(
@AuthenticationPrincipal UserPrincipal userPrincipal,
@Parameter(description = "마지막 읽은 게시글 ID (커서, 선택)")
@RequestParam(required = false) Long lastReadPostId,
@Parameter(description = "페이지 크기 (기본값: 20)")
@RequestParam(defaultValue = "20") int size
) {
ReadPostListResponse response = activityQueryService.getReadPosts(userPrincipal.getId(), lastReadPostId, size);
return BaseResponse.of(SuccessCode.OK, response);
}

@Operation(
summary = "읽은 게시글 저장",
description = "사용자가 특정 게시글을 읽은 기록을 저장합니다. 읽은 시간과 체류 시간을 기록합니다."
Expand Down Expand Up @@ -96,5 +113,4 @@ public ResponseEntity<BaseResponse<Void>> deleteBookmark(
activityCommandService.deleteBookmark(userPrincipal.getId(), request);
return BaseResponse.of(SuccessCode.OK);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import com.techfork.domain.activity.dto.BookmarkDto;
import com.techfork.domain.activity.dto.BookmarkListResponse;
import com.techfork.domain.activity.dto.ReadPostDto;
import com.techfork.domain.activity.dto.ReadPostListResponse;
import org.springframework.stereotype.Component;

import java.util.List;
Expand All @@ -21,4 +23,17 @@ public BookmarkListResponse toBookmarkListResponse(List<BookmarkDto> bookmarks,
.hasNext(hasNext)
.build();
}

public ReadPostListResponse toReadPostListResponse(List<ReadPostDto> readPosts, int requestedSize) {
boolean hasNext = readPosts.size() > requestedSize;
List<ReadPostDto> content = hasNext ? readPosts.subList(0, requestedSize) : readPosts;

Long lastReadPostId = content.isEmpty() ? null : content.get(content.size() - 1).readPostId();

return ReadPostListResponse.builder()
.readPosts(content)
.lastReadPostId(lastReadPostId)
.hasNext(hasNext)
.build();
}
}
24 changes: 24 additions & 0 deletions src/main/java/com/techfork/domain/activity/dto/ReadPostDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.techfork.domain.activity.dto;

import lombok.Builder;

import java.time.LocalDateTime;
import java.util.List;

@Builder
public record ReadPostDto(
Long readPostId,
Long postId,
String title,
String shortSummary,
String url,
String companyName,
String logoUrl,
LocalDateTime publishedAt,
String thumbnailUrl,
Long viewCount,
List<String> keywords,
Boolean isBookmarked,
LocalDateTime readAt
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.techfork.domain.activity.dto;

import lombok.Builder;

import java.util.List;

@Builder
public record ReadPostListResponse(
List<ReadPostDto> readPosts,
Long lastReadPostId,
boolean hasNext
) {
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.techfork.domain.activity.repository;

import com.techfork.domain.activity.dto.ReadPostDto;
import com.techfork.domain.activity.entity.ReadPost;
import com.techfork.domain.post.entity.Post;
import com.techfork.domain.user.entity.User;
Expand All @@ -22,4 +23,22 @@ public interface ReadPostRepository extends JpaRepository<ReadPost, Long> {
ORDER BY rp.readAt DESC
""")
List<ReadPost> findRecentReadPostsByUserIdWithMinDuration(@Param("userId") Long userId, Pageable pageable);

@Query("""
SELECT new com.techfork.domain.activity.dto.ReadPostDto(
rp.id, p.id, p.title, p.shortSummary, p.url, t.companyName, t.logoUrl,
p.publishedAt, p.thumbnailUrl, p.viewCount, null, null, rp.readAt
)
FROM ReadPost rp
JOIN rp.post p
JOIN p.techBlog t
WHERE rp.user.id = :userId
AND (:lastReadPostId IS NULL OR rp.id < :lastReadPostId)
ORDER BY rp.id DESC
""")
List<ReadPostDto> findReadPostsWithCursor(
@Param("userId") Long userId,
@Param("lastReadPostId") Long lastReadPostId,
Pageable pageable
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import com.techfork.domain.activity.converter.ActivityConverter;
import com.techfork.domain.activity.dto.BookmarkDto;
import com.techfork.domain.activity.dto.BookmarkListResponse;
import com.techfork.domain.activity.dto.ReadPostDto;
import com.techfork.domain.activity.dto.ReadPostListResponse;
import com.techfork.domain.activity.repository.ReadPostRepository;
import com.techfork.domain.activity.repository.ScrabPostRepository;
import com.techfork.domain.post.dto.PostInfoDto;
import com.techfork.domain.post.entity.PostKeyword;
import com.techfork.domain.post.repository.PostKeywordRepository;
import com.techfork.domain.user.entity.User;
Expand All @@ -17,7 +19,6 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.awt.print.Book;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
Expand All @@ -31,6 +32,7 @@ public class ActivityQueryService {
private final UserRepository userRepository;
private final ScrabPostRepository scrabPostRepository;
private final PostKeywordRepository postKeywordRepository;
private final ReadPostRepository readPostRepository;
private final ActivityConverter activityConverter;

public BookmarkListResponse getBookmarks(Long userId, Long lastBookmarkId, int size) {
Expand All @@ -44,6 +46,15 @@ public BookmarkListResponse getBookmarks(Long userId, Long lastBookmarkId, int s
return activityConverter.toBookmarkListResponse(bookmarksWithKeywords, size);
}

public ReadPostListResponse getReadPosts(Long userId, Long lastReadPostId, int size) {
PageRequest pageRequest = PageRequest.of(0, size + 1);
List<ReadPostDto> readPosts = readPostRepository.findReadPostsWithCursor(userId, lastReadPostId, pageRequest);
List<ReadPostDto> readPostsWithKeywords = attachKeywordsToReadPosts(readPosts);
List<ReadPostDto> readPostsWithBookmarks = attachBookmarksToReadPosts(readPostsWithKeywords, userId);

return activityConverter.toReadPostListResponse(readPostsWithBookmarks, size);
}

private List<BookmarkDto> attachKeywordsToPostInfoList(List<BookmarkDto> bookmarks) {
if (bookmarks.isEmpty()) {
return bookmarks;
Expand Down Expand Up @@ -77,4 +88,69 @@ private List<BookmarkDto> attachKeywordsToPostInfoList(List<BookmarkDto> bookmar
.build())
.toList();
}

private List<ReadPostDto> attachKeywordsToReadPosts(List<ReadPostDto> readPosts) {
if (readPosts.isEmpty()) {
return readPosts;
}

List<Long> postIds = readPosts.stream()
.map(ReadPostDto::postId)
.toList();

Map<Long, List<String>> keywordMap = postKeywordRepository.findByPostIdIn(postIds)
.stream()
.collect(Collectors.groupingBy(
pk -> pk.getPost().getId(),
Collectors.mapping(PostKeyword::getKeyword, Collectors.toList())
));

return readPosts.stream()
.map(readPost -> ReadPostDto.builder()
.readPostId(readPost.readPostId())
.postId(readPost.postId())
.title(readPost.title())
.shortSummary(readPost.shortSummary())
.url(readPost.url())
.companyName(readPost.companyName())
.logoUrl(readPost.logoUrl())
.publishedAt(readPost.publishedAt())
.thumbnailUrl(readPost.thumbnailUrl())
.viewCount(readPost.viewCount())
.keywords(keywordMap.getOrDefault(readPost.postId(), List.of()))
.isBookmarked(null)
.readAt(readPost.readAt())
.build())
.toList();
}

private List<ReadPostDto> attachBookmarksToReadPosts(List<ReadPostDto> readPosts, Long userId) {
if (readPosts.isEmpty()) {
return readPosts;
}

List<Long> postIds = readPosts.stream()
.map(ReadPostDto::postId)
.toList();

List<Long> bookmarkedPostIds = scrabPostRepository.findBookmarkedPostIds(userId, postIds);

return readPosts.stream()
.map(readPost -> ReadPostDto.builder()
.readPostId(readPost.readPostId())
.postId(readPost.postId())
.title(readPost.title())
.shortSummary(readPost.shortSummary())
.url(readPost.url())
.companyName(readPost.companyName())
.logoUrl(readPost.logoUrl())
.publishedAt(readPost.publishedAt())
.thumbnailUrl(readPost.thumbnailUrl())
.viewCount(readPost.viewCount())
.keywords(readPost.keywords())
.isBookmarked(bookmarkedPostIds.contains(readPost.postId()))
.readAt(readPost.readAt())
.build())
.toList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,105 @@ void getBookmarks_Success_WithCursor() throws Exception {
.andExpect(jsonPath("$.data.lastBookmarkId").exists());
}

// ===== 읽은 게시글 조회 테스트 =====

@Test
@DisplayName("읽은 게시글 목록 조회 성공 - 빈 목록")
void getReadPosts_Success_Empty() throws Exception {
// When & Then
mockMvc.perform(get("/api/v1/activities/read-posts")
.header("Authorization", "Bearer " + accessToken)
.param("size", "20"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.isSuccess").value(true))
.andExpect(jsonPath("$.data.readPosts").isArray())
.andExpect(jsonPath("$.data.readPosts").isEmpty())
.andExpect(jsonPath("$.data.hasNext").value(false));
}

@Test
@DisplayName("읽은 게시글 목록 조회 성공 - 여러 개")
void getReadPosts_Success_Multiple() throws Exception {
// Given - 읽은 게시글 기록 생성 (순서대로 저장)
ReadPost readPost1 = ReadPost.create(testUser, testPost1, LocalDateTime.now().minusHours(2), 300);
ReadPost readPost2 = ReadPost.create(testUser, testPost2, LocalDateTime.now().minusHours(1), 150);
readPostRepository.save(readPost1);
readPostRepository.save(readPost2);

// When & Then
mockMvc.perform(get("/api/v1/activities/read-posts")
.header("Authorization", "Bearer " + accessToken)
.param("size", "20"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.isSuccess").value(true))
.andExpect(jsonPath("$.data.readPosts").isArray())
.andExpect(jsonPath("$.data.readPosts.length()").value(2))
.andExpect(jsonPath("$.data.hasNext").value(false))
// 첫 번째 읽은 게시글 DTO 전체 필드 검증 (ID 역순이므로 readPost2)
.andExpect(jsonPath("$.data.readPosts[0].readPostId").value(readPost2.getId()))
.andExpect(jsonPath("$.data.readPosts[0].postId").value(testPost2.getId()))
.andExpect(jsonPath("$.data.readPosts[0].title").value("테스트 게시글 2"))
.andExpect(jsonPath("$.data.readPosts[0].shortSummary").value("게시글 2의 짧은 요약"))
.andExpect(jsonPath("$.data.readPosts[0].url").value("https://test.com/post/2"))
.andExpect(jsonPath("$.data.readPosts[0].companyName").value("테스트회사"))
.andExpect(jsonPath("$.data.readPosts[0].logoUrl").value("https://test.com/logo.png"))
.andExpect(jsonPath("$.data.readPosts[0].publishedAt").exists())
.andExpect(jsonPath("$.data.readPosts[0].thumbnailUrl").value("https://test.com/thumb2.png"))
.andExpect(jsonPath("$.data.readPosts[0].viewCount").value(0))
.andExpect(jsonPath("$.data.readPosts[0].keywords").isArray())
.andExpect(jsonPath("$.data.readPosts[0].isBookmarked").value(false))
.andExpect(jsonPath("$.data.readPosts[0].readAt").exists());
}

@Test
@DisplayName("읽은 게시글 목록 조회 성공 - 북마크 상태 포함")
void getReadPosts_Success_WithBookmarks() throws Exception {
// Given - 읽은 게시글 기록 생성 (순서대로 저장)
ReadPost readPost1 = ReadPost.create(testUser, testPost1, LocalDateTime.now().minusHours(2), 300);
ReadPost readPost2 = ReadPost.create(testUser, testPost2, LocalDateTime.now().minusHours(1), 150);
readPostRepository.save(readPost1);
readPostRepository.save(readPost2);

// Given - testPost2만 북마크 (readPost2가 먼저 조회됨)
ScrabPost bookmark = ScrabPost.create(testUser, testPost2, LocalDateTime.now());
scrabPostRepository.save(bookmark);

// When & Then
mockMvc.perform(get("/api/v1/activities/read-posts")
.header("Authorization", "Bearer " + accessToken)
.param("size", "20"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.readPosts.length()").value(2))
.andExpect(jsonPath("$.data.readPosts[0].postId").value(testPost2.getId()))
.andExpect(jsonPath("$.data.readPosts[0].isBookmarked").value(true)) // testPost2
.andExpect(jsonPath("$.data.readPosts[1].postId").value(testPost1.getId()))
.andExpect(jsonPath("$.data.readPosts[1].isBookmarked").value(false)); // testPost1
}

@Test
@DisplayName("읽은 게시글 목록 조회 성공 - 커서 기반 페이징")
void getReadPosts_Success_WithCursor() throws Exception {
// Given - 여러 개의 읽은 게시글 생성
ReadPost readPost1 = ReadPost.create(testUser, testPost1, LocalDateTime.now().minusHours(1), 300);
ReadPost readPost2 = ReadPost.create(testUser, testPost2, LocalDateTime.now().minusHours(2), 150);
readPostRepository.save(readPost1);
readPostRepository.save(readPost2);

// When & Then - 첫 페이지 조회
mockMvc.perform(get("/api/v1/activities/read-posts")
.header("Authorization", "Bearer " + accessToken)
.param("size", "1"))
.andDo(print())
.andExpect(status().isOk())
.andExpect(jsonPath("$.isSuccess").value(true))
.andExpect(jsonPath("$.data.readPosts.length()").value(1))
.andExpect(jsonPath("$.data.hasNext").value(true))
.andExpect(jsonPath("$.data.lastReadPostId").exists());
}

// ===== 통합 시나리오 테스트 =====

@Test
Expand Down Expand Up @@ -494,4 +593,28 @@ void integrationScenario_AddGetDeleteBookmark() throws Exception {
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.bookmarks").isEmpty());
}

@Test
@DisplayName("통합 시나리오 - 게시글 읽기 후 읽은 목록 조회")
void integrationScenario_ReadPost_GetReadPosts() throws Exception {
// 1. 게시글 읽기 기록 저장
ReadPostRequest readRequest = new ReadPostRequest(
testPost1.getId(),
LocalDateTime.now(),
300
);
mockMvc.perform(post("/api/v1/activities/read-posts")
.header("Authorization", "Bearer " + accessToken)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(readRequest)))
.andExpect(status().isCreated());

// 2. 읽은 게시글 목록 조회
mockMvc.perform(get("/api/v1/activities/read-posts")
.header("Authorization", "Bearer " + accessToken)
.param("size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.readPosts.length()").value(1))
.andExpect(jsonPath("$.data.readPosts[0].postId").value(testPost1.getId()));
}
}
Loading
Loading