Skip to content

Commit d02b001

Browse files
authored
Merge pull request #107 from Capstone-OpenStep/feature/#100-badge-api
Refactor: 트렌딩 이슈, 사용자 맞춤 이슈, 키워드 검색 이슈 조회시 페이징으로 변횐
2 parents b2e5bd2 + fffb561 commit d02b001

File tree

3 files changed

+122
-17
lines changed

3 files changed

+122
-17
lines changed

src/main/java/com/chungang/capstone/openstep/domain/Issue/controller/IssueController.java

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@
1616
import io.swagger.v3.oas.annotations.Parameter;
1717
import io.swagger.v3.oas.annotations.tags.Tag;
1818
import lombok.RequiredArgsConstructor;
19+
import jakarta.validation.constraints.Max;
20+
import jakarta.validation.constraints.Min;
1921

22+
import org.springframework.data.domain.PageRequest;
23+
import org.springframework.validation.annotation.Validated;
2024
import org.springframework.web.bind.annotation.RequestMapping;
2125
import org.springframework.web.bind.annotation.RestController;
2226

@@ -30,6 +34,7 @@
3034

3135
@RestController
3236
@RequiredArgsConstructor
37+
@Validated
3338
@RequestMapping("/issues")
3439
@Tag(name = "이슈 API", description = "GitHub 이슈 관련 API입니다.")
3540
public class IssueController {
@@ -40,8 +45,8 @@ public class IssueController {
4045
// 트렌딩 이슈 목록 조회 API
4146
@GetMapping("/trending")
4247
@Operation(summary = "트렌딩 issue 조회 API", description = "현재 인기 있는 트렌딩한 오픈소스 이슈를 조회합니다.")
43-
public ApiResponse<IssueResponseDTO.IssueListDTO> getTrendingIssues() {
44-
List<Issue> issues = issueQueryService.getTrendingIssues();
48+
public ApiResponse<IssueResponseDTO.IssueListDTO> getTrendingIssues(@Parameter(description = "페이지 번호 (0~3)") @RequestParam(defaultValue = "0") @Min(0) @Max(3) int page) {
49+
List<Issue> issues = issueQueryService.getTrendingIssues(PageRequest.of(page, 5));
4550
Member member = SecurityUtils.getCurrentMemberOrNull();
4651
List<Long> bookmarkedIds = (member != null) ? issueQueryService.getBookmarkedIssueIds(member.getMemberId()) : List.of(); // 비로그인시에는 빈 리스트 반환
4752
return ApiResponse.onSuccess(SuccessStatus.ISSUE_GET_TRENDING_OK, IssueConverter.toIssueListDTO(issues, bookmarkedIds));
@@ -68,9 +73,9 @@ public ApiResponse<IssueResponseDTO.IssueAssignmentDTO> assignIssueToUser(
6873
// 사용자 맞춤 이슈 추천
6974
@GetMapping("/suggest")
7075
@Operation(summary = "사용자 맞춤 이슈 추천 API", description = "사용자의 관심사에 맞는 오픈소스 이슈를 추천합니다.")
71-
public ApiResponse<IssueResponseDTO.IssueListDTO> suggestIssues() {
76+
public ApiResponse<IssueResponseDTO.IssueListDTO> suggestIssues(@Parameter(description = "페이지 번호 (0~3)") @RequestParam(defaultValue = "0") @Min(0) @Max(3) int page) {
7277
Member member = SecurityUtils.getCurrentMember();
73-
List<Issue> issues = issueQueryService.getSuggestedIssues(member);
78+
List<Issue> issues = issueQueryService.getSuggestedIssues(member, PageRequest.of(page, 5));
7479
List<Long> bookmarkedIds = issueQueryService.getBookmarkedIssueIds(member.getMemberId());
7580
return ApiResponse.onSuccess(SuccessStatus.ISSUE_GET_SUGGEST_OK, IssueConverter.toIssueListDTO(issues, bookmarkedIds));
7681
}
@@ -90,10 +95,11 @@ public ApiResponse<IssueResponseDTO.IssueListDTO> suggestIssues() {
9095
public ApiResponse<IssueResponseDTO.IssueListDTO> searchIssuesByKeyword(
9196
@RequestParam String search,
9297
@RequestParam(required = false) List<InterestLanguage> languages,
93-
@RequestParam(required = false) UpdatePeriod updatePeriod
98+
@RequestParam(required = false) UpdatePeriod updatePeriod,
99+
@Parameter(description = "페이지 번호 (0~3)") @RequestParam(defaultValue = "0") @Min(0) @Max(3) int page
94100
) {
95101
Member member = SecurityUtils.getCurrentMember();
96-
List<Issue> issues = issueQueryService.searchGitHubIssuesByKeywordAndFilters(search, languages, updatePeriod);
102+
List<Issue> issues = issueQueryService.searchGitHubIssuesByKeywordAndFilters(search, languages, updatePeriod, PageRequest.of(page, 5));
97103
List<Long> bookmarkedIds = issueQueryService.getBookmarkedIssueIds(member.getMemberId());
98104
return ApiResponse.onSuccess(SuccessStatus.ISSUE_SEARCH_BY_KEYWORD_OK,
99105
IssueConverter.toIssueListDTO(issues, bookmarkedIds));

src/main/java/com/chungang/capstone/openstep/domain/Issue/service/IssueCacheService.java

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ public class IssueCacheService {
1818
private final ObjectMapper objectMapper;
1919

2020
private final long CACHE_EXPIRE_SECONDS = 60L * 60 * 24 * 60; // TTL 60일
21+
private static final String TRENDING_CACHE_KEY = "trending:issues";
2122

2223
// 사용자별 추천 캐시 (기존 유지)
2324
public void saveRecommendedIssues(Long memberId, List<Issue> issues) {
@@ -64,6 +65,31 @@ public void evictInterestHash(Long memberId) {
6465
}
6566

6667

68+
public void saveTrendingIssues(List<Issue> issues) {
69+
try {
70+
String json = objectMapper.writeValueAsString(issues);
71+
redisTemplate.opsForValue().set(TRENDING_CACHE_KEY, json, 6, TimeUnit.HOURS);
72+
} catch (Exception e) {
73+
throw new RuntimeException("트렌딩 이슈 캐싱 실패", e);
74+
}
75+
}
76+
77+
public List<Issue> getTrendingIssuesFromCache() {
78+
String cached = redisTemplate.opsForValue().get(TRENDING_CACHE_KEY);
79+
if (cached == null) return null;
80+
try {
81+
return objectMapper.readValue(cached, new TypeReference<List<Issue>>() {});
82+
} catch (Exception e) {
83+
throw new RuntimeException("트렌딩 이슈 캐시 역직렬화 실패", e);
84+
}
85+
}
86+
87+
public void evictTrendingIssues() {
88+
redisTemplate.delete(TRENDING_CACHE_KEY);
89+
}
90+
91+
92+
6793

6894
public void evict(Long memberId) {
6995
redisTemplate.delete(generateMemberKey(memberId));

src/main/java/com/chungang/capstone/openstep/domain/Issue/service/IssueQueryService.java

Lines changed: 84 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import com.chungang.capstone.openstep.global.apiPayload.exception.handler.IssueHandler;
2222
import lombok.RequiredArgsConstructor;
2323
import lombok.extern.slf4j.Slf4j;
24+
import org.springframework.data.domain.Pageable;
2425
import org.springframework.stereotype.Service;
2526

2627
import java.time.LocalDateTime;
@@ -43,9 +44,51 @@ public class IssueQueryService {
4344
private final MemberDomainRepository memberDomainRepository;
4445
private final BookmarkRepository bookmarkRepository;
4546

46-
public List<Issue> getTrendingIssues() {
47-
List<Repo> repos = repoRepository.findAll();
47+
// public List<Issue> getTrendingIssues(Pageable pageable) {
48+
// List<Repo> repos = repoRepository.findAll();
49+
// List<Issue> allIssues = new ArrayList<>();
50+
// for (Repo repo : repoRepository.findTop10ByOrderByStarsDesc()) {
51+
// GitHubIssueResponse res = gitHubGraphQLService.fetchIssuesByRepo(repo.getOwnerName(), repo.getRepoName());
52+
// if (res != null && res.getData().getRepository() != null) {
53+
// List<Issue> issues = res.getData().getRepository().getIssues().getNodes().stream()
54+
// .map(node -> {
55+
// Optional<Issue> existing = issueRepository.findByGithubUrl(node.getUrl());
56+
// if (existing.isPresent()) {
57+
// Issue issue = existing.get();
58+
// LocalDateTime updated = OffsetDateTime.parse(node.getUpdatedAt()).toLocalDateTime();
59+
// if (issue.getUpdatedAt().isEqual(updated)) return issue; // skip
60+
// }
61+
// return saveIfNotExistsOrUpdate(node, repo);
62+
// })
63+
// .filter(Objects::nonNull)
64+
// .toList();
65+
// allIssues.addAll(issues);
66+
// }
67+
// if (allIssues.size() >= 20) break;
68+
// }
69+
// List<Issue> top20 = allIssues.stream()
70+
// .sorted(Comparator.comparing(Issue::getUpdatedAt).reversed())
71+
// .limit(20)
72+
// .toList();
73+
//
74+
// int start = (int) pageable.getOffset();
75+
// int end = Math.min(start + pageable.getPageSize(), top20.size());
76+
// if (start >= end) return List.of();
77+
// return top20.subList(start, end);
78+
// }
79+
80+
public List<Issue> getTrendingIssues(Pageable pageable) {
81+
List<Issue> cached = issueCacheService.getTrendingIssuesFromCache();
82+
if (cached != null && !cached.isEmpty()) {
83+
log.info("[TRENDING] Cache hit: {} issues", cached.size());
84+
int start = (int) pageable.getOffset();
85+
int end = Math.min(start + pageable.getPageSize(), cached.size());
86+
return (start >= end) ? List.of() : cached.subList(start, end);
87+
}
88+
89+
log.info("[TRENDING] Cache miss: fetching from GitHub...");
4890
List<Issue> allIssues = new ArrayList<>();
91+
4992
for (Repo repo : repoRepository.findTop10ByOrderByStarsDesc()) {
5093
GitHubIssueResponse res = gitHubGraphQLService.fetchIssuesByRepo(repo.getOwnerName(), repo.getRepoName());
5194
if (res != null && res.getData().getRepository() != null) {
@@ -55,7 +98,7 @@ public List<Issue> getTrendingIssues() {
5598
if (existing.isPresent()) {
5699
Issue issue = existing.get();
57100
LocalDateTime updated = OffsetDateTime.parse(node.getUpdatedAt()).toLocalDateTime();
58-
if (issue.getUpdatedAt().isEqual(updated)) return issue; // skip
101+
if (issue.getUpdatedAt().isEqual(updated)) return issue;
59102
}
60103
return saveIfNotExistsOrUpdate(node, repo);
61104
})
@@ -64,10 +107,22 @@ public List<Issue> getTrendingIssues() {
64107
allIssues.addAll(issues);
65108
}
66109
}
67-
return allIssues;
110+
111+
// 정렬 및 캐시 저장
112+
List<Issue> sorted = allIssues.stream()
113+
.sorted(Comparator.comparing(Issue::getUpdatedAt).reversed())
114+
.toList();
115+
116+
issueCacheService.saveTrendingIssues(sorted);
117+
118+
int start = (int) pageable.getOffset();
119+
int end = Math.min(start + pageable.getPageSize(), sorted.size());
120+
return (start >= end) ? List.of() : sorted.subList(start, end);
68121
}
69122

70-
public List<Issue> getSuggestedIssues(Member member) {
123+
124+
125+
public List<Issue> getSuggestedIssues(Member member, Pageable pageable) {
71126
Long memberId = member.getMemberId();
72127
log.info("[ISSUE_RECOMMEND] Start for memberId = {}", memberId);
73128

@@ -78,7 +133,10 @@ public List<Issue> getSuggestedIssues(Member member) {
78133
List<Issue> cached = issueCacheService.getRecommendedIssues(memberId);
79134
if (cached != null && currentHash.equals(cachedHash)) {
80135
log.info("[ISSUE_RECOMMEND] Cache hit: {} issues (interests same)", cached.size());
81-
return cached;
136+
int start = (int) pageable.getOffset();
137+
int end = Math.min(start + pageable.getPageSize(), cached.size());
138+
if (start >= end) return List.of();
139+
return cached.subList(start, end);
82140
}
83141

84142
// 관심사 바뀐 경우 캐시 무효화
@@ -205,7 +263,10 @@ public List<Issue> getSuggestedIssues(Member member) {
205263
if (summarized.size() < 20) {
206264
log.warn("[ISSUE_RECOMMEND] WARNING: Recommended issue count below target ({} / 20)", summarized.size());
207265
}
208-
return summarized;
266+
int start = (int) pageable.getOffset();
267+
int end = Math.min(start + pageable.getPageSize(), summarized.size());
268+
if (start >= end) return List.of();
269+
return summarized.subList(start, end);
209270
}
210271

211272

@@ -307,7 +368,12 @@ public List<Long> getBookmarkedIssueIds(Long memberId) {
307368
.collect(Collectors.toList());
308369
}
309370

310-
public List<Issue> searchGitHubIssuesByKeywordAndFilters(String keyword, List<InterestLanguage> languages, UpdatePeriod updatePeriod) {
371+
public List<Issue> searchGitHubIssuesByKeywordAndFilters(
372+
String keyword,
373+
List<InterestLanguage> languages,
374+
UpdatePeriod updatePeriod,
375+
Pageable pageable
376+
) {
311377
String languageQuery = (languages != null && !languages.isEmpty())
312378
? " language:" + languages.stream()
313379
.map(InterestLanguage::getLabel)
@@ -328,7 +394,7 @@ public List<Issue> searchGitHubIssuesByKeywordAndFilters(String keyword, List<In
328394
.map(GitHubIssueResponse.Search::getEdges)
329395
.orElse(Collections.emptyList());
330396

331-
return edges.stream()
397+
List<Issue> filtered = edges.stream()
332398
.map(GitHubIssueResponse.Edge::getNode)
333399
.filter(Objects::nonNull)
334400
.filter(node -> node.getUpdatedAt() != null)
@@ -337,14 +403,21 @@ public List<Issue> searchGitHubIssuesByKeywordAndFilters(String keyword, List<In
337403
OffsetDateTime updatedAt = OffsetDateTime.parse(node.getUpdatedAt());
338404
return updatedAt.isAfter(threshold) ? node : null;
339405
} catch (Exception e) {
340-
return null; // 날짜 파싱 실패 시 무시
406+
return null;
341407
}
342408
})
343409
.filter(Objects::nonNull)
344-
.limit(20)
345410
.map(IssueConverter::fromGitHubIssueNode)
411+
.limit(20)
346412
.toList();
413+
414+
int start = (int) pageable.getOffset();
415+
int end = Math.min(start + pageable.getPageSize(), filtered.size());
416+
if (start >= end) return List.of();
417+
418+
return filtered.subList(start, end);
347419
}
348420

349421

422+
350423
}

0 commit comments

Comments
 (0)