Skip to content

Commit bfabae5

Browse files
authored
Merge pull request #93 from Final-Project-Team-Temporary/feature/#87-recently-viewed-article
[FEATURE] 최근 조회한 기사 목록 조회
2 parents 3d5508c + 58d561b commit bfabae5

7 files changed

Lines changed: 111 additions & 8 deletions

File tree

src/main/java/com/example/whiplash/article/original/repository/ArticleReadRedisRepository.java

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
import java.time.Duration;
44
import java.time.LocalDate;
55
import java.time.format.DateTimeFormatter;
6+
import java.util.Set;
67

7-
import org.springframework.data.redis.core.RedisTemplate;
8+
import org.springframework.data.redis.core.StringRedisTemplate;
89
import org.springframework.stereotype.Repository;
910

1011
import lombok.RequiredArgsConstructor;
@@ -14,21 +15,34 @@
1415
public class ArticleReadRedisRepository {
1516
private static final String KEY = "article:read:userId:%d:date:%s";
1617

17-
private final RedisTemplate<String, Object> redisTemplate;
18+
private final StringRedisTemplate redisTemplate;
1819

1920
public boolean isRead(Long userId, String articleId) {
20-
Boolean isRead = redisTemplate.opsForSet().isMember(getKey(userId), articleId);
21-
return isRead != null && isRead;
21+
Double score = redisTemplate.opsForZSet().score(getKey(userId), articleId);
22+
return score != null;
2223
}
2324

2425
public void readArticle(Long userId, String articleId) {
2526
String key = getKey(userId);
2627

27-
redisTemplate.opsForSet().add(key, articleId);
28+
// 현재 시간을 timestamp로 사용 (score)
29+
double score = (double) System.currentTimeMillis();
30+
redisTemplate.opsForZSet().add(key, articleId, score);
2831

2932
redisTemplate.expire(key, Duration.ofDays(1));
3033
}
3134

35+
public Set<String> getArticleIdsReadByUserOnDate(Long userId, LocalDate date, int limit) {
36+
String dateStr = date.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
37+
String key = String.format(KEY, userId, dateStr);
38+
39+
// ZSET에서 score 내림차순으로 최대 limit개 조회 (최신 순)
40+
// reverseRange: 높은 score(최근 시간)부터 반환
41+
Set<String> articleIds = redisTemplate.opsForZSet().reverseRange(key, 0, limit - 1);
42+
43+
return articleIds != null ? articleIds : Set.of();
44+
}
45+
3246
private String getKey(Long userId) {
3347
String today = LocalDate.now()
3448
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));

src/main/java/com/example/whiplash/article/original/service/ArticleQueryService.java

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import com.example.whiplash.article.original.web.dto.response.ArticleDetailResponse;
1313
import com.example.whiplash.article.original.web.dto.response.ArticleListItemResponse;
1414
import com.example.whiplash.article.original.web.dto.response.ArticleResponse;
15+
import com.example.whiplash.article.original.web.dto.response.RecentlyViewedArticleResponse;
1516
import com.example.whiplash.bookmark.entity.ArticleBookmark;
1617
import com.example.whiplash.bookmark.repository.ArticleBookmarkRepository;
1718
import com.example.whiplash.daily.learning.entity.LearningType;
@@ -27,10 +28,13 @@
2728
import org.springframework.stereotype.Service;
2829
import org.springframework.transaction.annotation.Transactional;
2930

31+
import java.time.LocalDate;
3032
import java.time.LocalDateTime;
33+
import java.util.Collections;
3134
import java.util.List;
3235
import java.util.Map;
3336
import java.util.Optional;
37+
import java.util.Set;
3438
import java.util.stream.Collectors;
3539

3640
@Slf4j
@@ -161,4 +165,35 @@ public Page<ArticleListItemResponse> searchArticles(String keyword, Pageable pag
161165
Page<Article> articles = articleRepository.searchByKeyword(keyword, pageable);
162166
return articles.map(ArticleConverter::toArticleListItemResponse);
163167
}
168+
169+
public List<RecentlyViewedArticleResponse> getRecentlyViewedArticles(Long userId, LocalDate date) {
170+
// 1. Redis ZSET에서 최신 10개 기사 ID 조회 (시간순 정렬됨)
171+
Set<String> articleIds = articleReadRedisRepository.getArticleIdsReadByUserOnDate(userId, date, 10);
172+
173+
if (articleIds == null || articleIds.isEmpty()) {
174+
log.info("사용자 {}의 {}에 읽은 기사가 없습니다", userId, date);
175+
return Collections.emptyList();
176+
}
177+
178+
// 2. MongoDB에서 기사 정보 조회 (배치 쿼리)
179+
List<Article> articles = articleRepository.findAllById(articleIds);
180+
181+
// 3. Redis에서 반환된 순서를 유지하기 위해 Map 생성
182+
Map<String, Article> articleMap = articles.stream()
183+
.collect(Collectors.toMap(Article::getId, article -> article));
184+
185+
// 4. Redis 순서대로 DTO 변환 (최신 읽은 순서 유지)
186+
List<RecentlyViewedArticleResponse> response = articleIds.stream()
187+
.map(articleMap::get)
188+
.filter(article -> article != null) // MongoDB에 없는 기사 필터링
189+
.map(article -> RecentlyViewedArticleResponse.builder()
190+
.id(article.getId())
191+
.title(article.getTitle())
192+
.build())
193+
.collect(Collectors.toList());
194+
195+
log.info("✅ 사용자 {}의 {} 최근 조회 기사: {}건", userId, date, response.size());
196+
197+
return response;
198+
}
164199
}

src/main/java/com/example/whiplash/article/original/web/controller/ArticleLoadController.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import com.example.whiplash.article.original.web.dto.response.ArticleListItemResponse;
77
import com.example.whiplash.article.original.web.dto.response.ArticleListResponse;
88
import com.example.whiplash.article.original.web.dto.response.ArticleResponse;
9+
import com.example.whiplash.article.original.web.dto.response.RecentlyViewedArticleResponse;
910
import com.example.whiplash.config.security.UserPrincipal;
1011
import com.example.whiplash.global.util.SecurityContextUtils;
1112

@@ -17,9 +18,12 @@
1718
import org.springframework.data.domain.Sort;
1819
import org.springframework.http.ResponseEntity;
1920
import org.springframework.security.core.annotation.AuthenticationPrincipal;
21+
import org.springframework.format.annotation.DateTimeFormat;
2022
import org.springframework.web.bind.annotation.*;
2123

24+
import java.time.LocalDate;
2225
import java.time.LocalDateTime;
26+
import java.util.List;
2327
import java.util.Map;
2428

2529
@RestController
@@ -68,6 +72,18 @@ public ResponseEntity<ApiResponse<ArticleListResponse>> searchArticles(
6872
return ResponseEntity.ok(ApiResponse.onSuccess(response));
6973
}
7074

75+
@GetMapping("/recently-viewed")
76+
public ResponseEntity<ApiResponse<List<RecentlyViewedArticleResponse>>> getRecentlyViewedArticles(
77+
@AuthenticationPrincipal UserPrincipal principal,
78+
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date) {
79+
80+
Long userId = principal.getUserId();
81+
82+
List<RecentlyViewedArticleResponse> articles = articleQueryService.getRecentlyViewedArticles(userId, date);
83+
84+
return ResponseEntity.ok(ApiResponse.onSuccess(articles));
85+
}
86+
7187
/**
7288
* 디버깅용: 전체 기사의 상태별 분포를 조회
7389
*/
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package com.example.whiplash.article.original.web.dto.response;
2+
3+
import lombok.Builder;
4+
5+
@Builder
6+
public record RecentlyViewedArticleResponse(
7+
String id,
8+
String title
9+
) {
10+
}

src/main/java/com/example/whiplash/domain/entity/BaseEntity.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import jakarta.persistence.EntityListeners;
44
import jakarta.persistence.MappedSuperclass;
55
import lombok.AccessLevel;
6+
import lombok.Builder;
67
import lombok.Getter;
78
import lombok.NoArgsConstructor;
89
import lombok.experimental.SuperBuilder;
@@ -14,8 +15,6 @@
1415

1516
@MappedSuperclass // JPA에서 상속받는 클래스의 공통 필드들을 테이블 컬럼으로 인식하게 해주는 어노테이션
1617
@Getter
17-
@SuperBuilder
18-
@NoArgsConstructor(access = AccessLevel.PROTECTED)
1918
@EntityListeners(AuditingEntityListener.class)
2019
public abstract class BaseEntity {
2120

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.example.whiplash.domain.entity;
2+
3+
import java.time.LocalDateTime;
4+
5+
import org.springframework.data.annotation.CreatedDate;
6+
import org.springframework.data.annotation.LastModifiedDate;
7+
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
8+
9+
import jakarta.persistence.EntityListeners;
10+
import jakarta.persistence.MappedSuperclass;
11+
import lombok.AccessLevel;
12+
import lombok.Getter;
13+
import lombok.NoArgsConstructor;
14+
import lombok.experimental.SuperBuilder;
15+
16+
@MappedSuperclass // JPA에서 상속받는 클래스의 공통 필드들을 테이블 컬럼으로 인식하게 해주는 어노테이션
17+
@Getter
18+
@SuperBuilder
19+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
20+
@EntityListeners(AuditingEntityListener.class)
21+
public class SuperBaseEntity {
22+
@CreatedDate
23+
private LocalDateTime createdAt;
24+
25+
@LastModifiedDate
26+
private LocalDateTime updatedAt;
27+
}

src/main/java/com/example/whiplash/log/quiz/entity/QuizSolveLog.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package com.example.whiplash.log.quiz.entity;
22

33
import com.example.whiplash.domain.entity.BaseEntity;
4+
import com.example.whiplash.domain.entity.SuperBaseEntity;
5+
46
import jakarta.persistence.Entity;
57
import jakarta.persistence.GeneratedValue;
68
import jakarta.persistence.GenerationType;
@@ -21,7 +23,7 @@
2123
@AllArgsConstructor
2224
@Inheritance(strategy = InheritanceType.TABLE_PER_CLASS)
2325
@Entity
24-
public abstract class QuizSolveLog extends BaseEntity {
26+
public abstract class QuizSolveLog extends SuperBaseEntity {
2527

2628
@Id
2729
@GeneratedValue(strategy = GenerationType.AUTO)

0 commit comments

Comments
 (0)