Skip to content

Commit 5b9d8c9

Browse files
committed
feat: Top3 투표 목록 조회 API V2 개발 (응답 수, 댓글 수만 포함) (#272)
1 parent 14acadb commit 5b9d8c9

File tree

4 files changed

+238
-2
lines changed

4 files changed

+238
-2
lines changed

src/main/java/com/moa/moa_server/domain/ranking/controller/RankingController.java

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22

33
import com.moa.moa_server.domain.global.dto.ApiResponse;
44
import com.moa.moa_server.domain.ranking.dto.TopVoteResponse;
5+
import com.moa.moa_server.domain.ranking.dto.TopVoteResponseV2;
56
import com.moa.moa_server.domain.ranking.service.RankingService;
7+
import com.moa.moa_server.domain.ranking.service.RankingServiceV2;
68
import io.swagger.v3.oas.annotations.Operation;
79
import io.swagger.v3.oas.annotations.tags.Tag;
810
import jakarta.annotation.Nullable;
@@ -14,16 +16,27 @@
1416
@Tag(name = "TopVote", description = "랭킹 투표 도메인 API")
1517
@RestController
1618
@RequiredArgsConstructor
17-
@RequestMapping("/api/v1/votes/top")
19+
@RequestMapping
1820
public class RankingController {
1921

2022
private final RankingService rankingService;
23+
private final RankingServiceV2 rankingServiceV2;
2124

2225
@Operation(summary = "Top3 투표 목록 조회", description = "그룹 내 하루 동안의 Top3 투표 목록을 조회합니다.")
23-
@GetMapping
26+
@GetMapping("/api/v1/votes/top")
2427
public ResponseEntity<ApiResponse<TopVoteResponse>> getTopVotes(
2528
@AuthenticationPrincipal Long userId, @RequestParam @Nullable Long groupId) {
2629
TopVoteResponse response = rankingService.getTopVotes(userId, groupId);
2730
return ResponseEntity.ok(new ApiResponse<>("SUCCESS", response));
2831
}
32+
33+
@Operation(
34+
summary = "Top3 투표 목록 조회 V2",
35+
description = "그룹 내 하루 동안의 Top3 투표 목록을 조회합니다. (내용, 응답 수, 댓글 수만 포함)")
36+
@GetMapping("/api/v2/votes/top")
37+
public ResponseEntity<ApiResponse<TopVoteResponseV2>> getTopVotesV2(
38+
@AuthenticationPrincipal Long userId, @RequestParam @Nullable Long groupId) {
39+
TopVoteResponseV2 response = rankingServiceV2.getTopVotesV2(userId, groupId);
40+
return ResponseEntity.ok(new ApiResponse<>("SUCCESS", response));
41+
}
2942
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.moa.moa_server.domain.ranking.dto;
2+
3+
import com.moa.moa_server.domain.vote.entity.Vote;
4+
import io.swagger.v3.oas.annotations.media.Schema;
5+
import lombok.Builder;
6+
7+
@Builder
8+
@Schema(description = "Top3 투표 정보")
9+
public record TopVoteItemV2(
10+
@Schema(description = "투표 ID", example = "123") Long voteId,
11+
@Schema(description = "투표가 속한 그룹 ID", example = "1") Long groupId,
12+
@Schema(description = "투표 본문 내용", example = "에어컨 추우신 분?") String content,
13+
@Schema(description = "응답 수", example = "10") int responsesCount,
14+
@Schema(description = "댓글 수", example = "5") int commentsCount) {
15+
public static TopVoteItemV2 from(Vote vote, int responsesCount, int commentsCount) {
16+
return TopVoteItemV2.builder()
17+
.voteId(vote.getId())
18+
.groupId(vote.getGroup().getId())
19+
.content(vote.getContent())
20+
.responsesCount(responsesCount)
21+
.commentsCount(commentsCount)
22+
.build();
23+
}
24+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package com.moa.moa_server.domain.ranking.dto;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import java.time.LocalDateTime;
5+
import java.util.List;
6+
import lombok.Builder;
7+
8+
@Builder
9+
@Schema(description = "Top3 투표 조회 응답 DTO")
10+
public record TopVoteResponseV2(
11+
@Schema(description = "그룹 ID", example = "1") Long groupId,
12+
@Schema(description = "랭킹 기준 시작 시간", example = "2025-07-21T00:00:00") LocalDateTime rankedFrom,
13+
@Schema(description = "랭킹 기준 종료 시간", example = "2025-07-21T01:00:00") LocalDateTime rankedTo,
14+
@Schema(description = "Top3 투표 목록") List<TopVoteItemV2> topVotes) {
15+
16+
public static TopVoteResponseV2 of(
17+
Long groupId, LocalDateTime from, LocalDateTime to, List<TopVoteItemV2> votes) {
18+
return TopVoteResponseV2.builder()
19+
.groupId(groupId)
20+
.rankedFrom(from)
21+
.rankedTo(to)
22+
.topVotes(votes)
23+
.build();
24+
}
25+
}
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
package com.moa.moa_server.domain.ranking.service;
2+
3+
import com.moa.moa_server.domain.comment.repository.CommentRepository;
4+
import com.moa.moa_server.domain.group.entity.Group;
5+
import com.moa.moa_server.domain.group.handler.GroupErrorCode;
6+
import com.moa.moa_server.domain.group.handler.GroupException;
7+
import com.moa.moa_server.domain.group.repository.GroupMemberRepository;
8+
import com.moa.moa_server.domain.group.repository.GroupRepository;
9+
import com.moa.moa_server.domain.ranking.dto.TopVoteItemV2;
10+
import com.moa.moa_server.domain.ranking.dto.TopVoteResponseV2;
11+
import com.moa.moa_server.domain.user.entity.User;
12+
import com.moa.moa_server.domain.user.handler.UserErrorCode;
13+
import com.moa.moa_server.domain.user.handler.UserException;
14+
import com.moa.moa_server.domain.user.repository.UserRepository;
15+
import com.moa.moa_server.domain.user.util.AuthUserValidator;
16+
import com.moa.moa_server.domain.vote.entity.Vote;
17+
import com.moa.moa_server.domain.vote.repository.VoteRepository;
18+
import com.moa.moa_server.domain.vote.repository.VoteResponseRepository;
19+
import com.moa.moa_server.domain.vote.service.vote_result.VoteResultService;
20+
import java.time.LocalDate;
21+
import java.time.LocalDateTime;
22+
import java.time.LocalTime;
23+
import java.time.ZoneOffset;
24+
import java.time.format.DateTimeFormatter;
25+
import java.util.*;
26+
import java.util.stream.Collectors;
27+
import lombok.RequiredArgsConstructor;
28+
import org.springframework.data.redis.core.StringRedisTemplate;
29+
import org.springframework.data.redis.core.ZSetOperations;
30+
import org.springframework.stereotype.Service;
31+
import org.springframework.transaction.annotation.Transactional;
32+
33+
@Service
34+
@RequiredArgsConstructor
35+
public class RankingServiceV2 {
36+
37+
private static final Long PUBLIC_GROUP_ID = 1L;
38+
39+
private final StringRedisTemplate redisTemplate;
40+
private final VoteRepository voteRepository;
41+
private final VoteResultService voteResultService;
42+
private final UserRepository userRepository;
43+
private final GroupRepository groupRepository;
44+
private final GroupMemberRepository groupMemberRepository;
45+
private final CommentRepository commentRepository;
46+
private final VoteResponseRepository voteResponseRepository;
47+
48+
@Transactional
49+
public TopVoteResponseV2 getTopVotesV2(Long userId, Long groupId) {
50+
// 유저/그룹/멤버십 검증
51+
User user = validateAndGetuser(userId);
52+
validateGroupMembership(user, groupId);
53+
54+
// 랭킹 기준 시간 계산
55+
LocalDate date = LocalDate.now(ZoneOffset.UTC);
56+
LocalDateTime from = date.atStartOfDay();
57+
LocalDateTime to = date.atTime(LocalTime.MAX);
58+
59+
List<TopVoteItemV2> topItems;
60+
61+
if (groupId != null) {
62+
topItems = getTopVotesByGroup(groupId);
63+
} else {
64+
topItems = getTopVotesByAllGroups(user);
65+
}
66+
67+
return TopVoteResponseV2.of(groupId, from, to, topItems);
68+
}
69+
70+
private User validateAndGetuser(Long userId) {
71+
// 유저 조회 및 활성 상태 확인
72+
User user =
73+
userRepository
74+
.findById(userId)
75+
.orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND));
76+
AuthUserValidator.validateActive(user);
77+
return user;
78+
}
79+
80+
private void validateGroupMembership(User user, Long groupId) {
81+
// 그룹 ID가 없으면 전체 그룹 조회이므로 검증 생략
82+
if (groupId == null) return;
83+
84+
// 그룹 조회
85+
Group group =
86+
groupRepository
87+
.findById(groupId)
88+
.orElseThrow(() -> new GroupException(GroupErrorCode.GROUP_NOT_FOUND));
89+
90+
// 비공개 그룹이라면 멤버십 확인
91+
if (!group.isPublicGroup()) {
92+
groupMemberRepository
93+
.findByGroupAndUser(group, user)
94+
.orElseThrow(() -> new GroupException(GroupErrorCode.FORBIDDEN));
95+
}
96+
}
97+
98+
private List<TopVoteItemV2> getTopVotesByGroup(Long groupId) {
99+
String key = buildRankingKey(groupId);
100+
101+
// top3 조회
102+
Set<String> voteIds = redisTemplate.opsForZSet().reverseRange(key, 0, 2);
103+
104+
// top3 데이터가 없으면 빈 리스트 반환
105+
if (voteIds == null || voteIds.isEmpty()) {
106+
return Collections.emptyList();
107+
}
108+
109+
// voteId 리스트를 기반으로 투표 정보 조회 후 TopVoteItem DTO로 변환
110+
List<Long> idList = voteIds.stream().map(Long::valueOf).collect(Collectors.toList());
111+
List<Vote> votes = voteRepository.findAllById(idList);
112+
113+
return votes.stream().map(this::toTopVoteItem).collect(Collectors.toList());
114+
}
115+
116+
private List<TopVoteItemV2> getTopVotesByAllGroups(User user) {
117+
List<Long> groupIds = groupMemberRepository.findGroupIdsByUser(user);
118+
groupIds.add(PUBLIC_GROUP_ID); // 공개 그룹 포함
119+
120+
return groupIds.stream()
121+
.flatMap(gid -> getTopVotesWithScore(gid).stream())
122+
.sorted(Map.Entry.<TopVoteItemV2, Double>comparingByValue().reversed())
123+
.limit(3)
124+
.map(Map.Entry::getKey)
125+
.collect(Collectors.toList());
126+
}
127+
128+
private String buildRankingKey(Long groupId) {
129+
String base = "ranking";
130+
String date = LocalDate.now(ZoneOffset.UTC).format(DateTimeFormatter.BASIC_ISO_DATE);
131+
String g = (groupId != null) ? String.valueOf(groupId) : "all";
132+
return base + ":" + g + ":" + date; // 예: ranking:3:20250716
133+
}
134+
135+
private List<Map.Entry<TopVoteItemV2, Double>> getTopVotesWithScore(Long groupId) {
136+
String key = buildRankingKey(groupId);
137+
Set<ZSetOperations.TypedTuple<String>> tuples =
138+
redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, 2);
139+
140+
if (tuples == null || tuples.isEmpty()) {
141+
return Collections.emptyList();
142+
}
143+
144+
List<Long> voteIds =
145+
tuples.stream()
146+
.map(t -> Long.valueOf(Objects.requireNonNull(t.getValue())))
147+
.collect(Collectors.toList());
148+
149+
Map<Long, Double> scoreMap =
150+
tuples.stream()
151+
.filter(t -> t.getScore() != null && t.getValue() != null)
152+
.collect(
153+
Collectors.toMap(
154+
t -> Long.valueOf(t.getValue()), // null 아님 보장됨
155+
ZSetOperations.TypedTuple::getScore));
156+
157+
List<Vote> votes = voteRepository.findAllById(voteIds);
158+
159+
return votes.stream()
160+
.map(
161+
vote -> {
162+
TopVoteItemV2 topVoteItem = toTopVoteItem(vote);
163+
Double score = scoreMap.get(vote.getId());
164+
return Map.entry(topVoteItem, score);
165+
})
166+
.collect(Collectors.toList());
167+
}
168+
169+
private TopVoteItemV2 toTopVoteItem(Vote vote) {
170+
int responsesCount = voteResponseRepository.countByVoteId(vote.getId());
171+
int commentsCount = commentRepository.countByVoteId(vote.getId());
172+
return TopVoteItemV2.from(vote, responsesCount, commentsCount);
173+
}
174+
}

0 commit comments

Comments
 (0)