Skip to content

Commit 257717f

Browse files
authored
feat: 랭킹 TOP20 조회 API 구현 (#73)
1 parent 4f440b3 commit 257717f

File tree

4 files changed

+307
-0
lines changed

4 files changed

+307
-0
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package targeter.aim.domain.challenge.controller;
2+
3+
import io.swagger.v3.oas.annotations.Operation;
4+
import io.swagger.v3.oas.annotations.tags.Tag;
5+
import lombok.RequiredArgsConstructor;
6+
import org.springframework.web.bind.annotation.GetMapping;
7+
import org.springframework.web.bind.annotation.RequestMapping;
8+
import org.springframework.web.bind.annotation.RestController;
9+
import targeter.aim.domain.challenge.dto.RankDto;
10+
import targeter.aim.domain.challenge.service.ChallengeRankService;
11+
import targeter.aim.system.security.annotation.NoJwtAuth;
12+
13+
import java.util.List;
14+
15+
@RestController
16+
@RequiredArgsConstructor
17+
@RequestMapping("/api/challenges")
18+
@Tag(name = "Challenge (Rank)", description = "랭킹 조회 API")
19+
public class ChallengeRankController {
20+
21+
private final ChallengeRankService challengeRankService;
22+
23+
@NoJwtAuth
24+
@GetMapping("/rank/top20")
25+
@Operation(
26+
summary = "랭킹 TOP 20 조회",
27+
description = "레벨이 가장 높은 유저 20명을 랭킹으로 조회합니다. 1~3위는 all/solo/vs 기록, 4~20위는 all 기록만 포함됩니다."
28+
)
29+
public List<RankDto.Top20RankResponse> getTop20Rank() {
30+
return challengeRankService.getTop20Rank();
31+
}
32+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package targeter.aim.domain.challenge.dto;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import lombok.*;
5+
import targeter.aim.domain.file.dto.FileDto;
6+
7+
public class RankDto {
8+
9+
@Data
10+
@Builder
11+
@NoArgsConstructor
12+
@AllArgsConstructor
13+
@Schema(description = "TOP20 랭킹 조회 응답")
14+
public static class Top20RankResponse {
15+
16+
@Schema(description = "순위")
17+
private int rank;
18+
19+
@Schema(description = "유저 ID")
20+
private Long userId;
21+
22+
@Schema(description = "유저 정보")
23+
private UserInfo userInfo;
24+
25+
@Schema(description = "전체 기록")
26+
private ChallengeRecord allRecord;
27+
28+
@Schema(description = "SOLO 기록 (1~3위만 내려줌)")
29+
private ChallengeRecord soloRecord;
30+
31+
@Schema(description = "VS 기록 (1~3위만 내려줌)")
32+
private ChallengeRecord vsRecord;
33+
}
34+
35+
@Data
36+
@Builder
37+
@NoArgsConstructor
38+
@AllArgsConstructor
39+
public static class UserInfo {
40+
private String nickname;
41+
private FileDto.FileResponse profileImage;
42+
private TierResponse tier;
43+
private Integer level;
44+
}
45+
46+
@Data
47+
@Builder
48+
@NoArgsConstructor
49+
@AllArgsConstructor
50+
public static class TierResponse {
51+
private String name;
52+
}
53+
54+
@Data
55+
@Builder
56+
@NoArgsConstructor
57+
@AllArgsConstructor
58+
@Schema(description = "챌린지 기록(시도/성공/실패/성공률)")
59+
public static class ChallengeRecord {
60+
private long attemptCount;
61+
private long successCount;
62+
private long failCount;
63+
64+
@Schema(description = "성공률(0~100)")
65+
private int successRate;
66+
}
67+
}
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package targeter.aim.domain.challenge.repository;
2+
3+
import com.querydsl.core.Tuple;
4+
import com.querydsl.core.types.dsl.CaseBuilder;
5+
import com.querydsl.core.types.dsl.NumberExpression;
6+
import com.querydsl.core.types.dsl.Expressions;
7+
import com.querydsl.jpa.JPAExpressions;
8+
import com.querydsl.jpa.impl.JPAQueryFactory;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.stereotype.Repository;
11+
import targeter.aim.domain.challenge.entity.ChallengeMode;
12+
13+
import java.util.HashMap;
14+
import java.util.List;
15+
import java.util.Map;
16+
17+
import static targeter.aim.domain.challenge.entity.QChallenge.challenge;
18+
import static targeter.aim.domain.challenge.entity.QWeeklyProgress.weeklyProgress;
19+
20+
@Repository
21+
@RequiredArgsConstructor
22+
public class ChallengeRankQueryRepository {
23+
24+
private final JPAQueryFactory queryFactory;
25+
26+
public record Record(long attempt, long success) {}
27+
28+
/**
29+
* challenge 1개 단위로 유저 성공률(complete/total) >= 0.7 이면 성공으로 카운트.
30+
* attempt = 참여한 챌린지 수
31+
* success = 성공 챌린지 수
32+
*/
33+
public Map<Long, Record> calcRecordByUsers(List<Long> userIds, ChallengeMode mode) {
34+
if (userIds == null || userIds.isEmpty()) {
35+
return Map.of();
36+
}
37+
38+
List<Tuple> attemptRows = queryFactory
39+
.select(weeklyProgress.user.id, weeklyProgress.challenge.id.countDistinct())
40+
.from(weeklyProgress)
41+
.join(weeklyProgress.challenge, challenge)
42+
.where(
43+
weeklyProgress.user.id.in(userIds),
44+
mode == null ? null : challenge.mode.eq(mode)
45+
)
46+
.groupBy(weeklyProgress.user.id)
47+
.fetch();
48+
49+
Map<Long, Long> attemptMap = new HashMap<>();
50+
for (Tuple t : attemptRows) {
51+
Long uid = t.get(weeklyProgress.user.id);
52+
Long cnt = t.get(weeklyProgress.challenge.id.countDistinct());
53+
attemptMap.put(uid, cnt == null ? 0L : cnt);
54+
}
55+
56+
// completeCnt / totalCnt (challenge 단위)
57+
NumberExpression<Integer> completeCnt = new CaseBuilder()
58+
.when(weeklyProgress.isComplete.isTrue()).then(1)
59+
.otherwise(0)
60+
.sum();
61+
62+
NumberExpression<Long> totalCnt = weeklyProgress.id.count();
63+
64+
NumberExpression<Double> ratio = Expressions.numberTemplate(
65+
Double.class,
66+
"({0} * 1.0) / {1}",
67+
completeCnt,
68+
totalCnt
69+
);
70+
71+
List<Tuple> successRows = queryFactory
72+
.select(weeklyProgress.user.id, weeklyProgress.challenge.id.countDistinct())
73+
.from(weeklyProgress)
74+
.join(weeklyProgress.challenge, challenge)
75+
.where(
76+
weeklyProgress.user.id.in(userIds),
77+
mode == null ? null : challenge.mode.eq(mode),
78+
Expressions.list(weeklyProgress.user.id, weeklyProgress.challenge.id).in(
79+
JPAExpressions
80+
.select(weeklyProgress.user.id, weeklyProgress.challenge.id)
81+
.from(weeklyProgress)
82+
.join(weeklyProgress.challenge, challenge)
83+
.where(
84+
weeklyProgress.user.id.in(userIds),
85+
mode == null ? null : challenge.mode.eq(mode)
86+
)
87+
.groupBy(weeklyProgress.user.id, weeklyProgress.challenge.id)
88+
.having(ratio.goe(0.7))
89+
)
90+
)
91+
.groupBy(weeklyProgress.user.id)
92+
.fetch();
93+
94+
Map<Long, Long> successMap = new HashMap<>();
95+
for (Tuple t : successRows) {
96+
Long uid = t.get(weeklyProgress.user.id);
97+
Long cnt = t.get(weeklyProgress.challenge.id.countDistinct());
98+
successMap.put(uid, cnt == null ? 0L : cnt);
99+
}
100+
101+
Map<Long, Record> result = new HashMap<>();
102+
for (Long uid : userIds) {
103+
long attempt = attemptMap.getOrDefault(uid, 0L);
104+
long success = successMap.getOrDefault(uid, 0L);
105+
result.put(uid, new Record(attempt, success));
106+
}
107+
return result;
108+
}
109+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package targeter.aim.domain.challenge.service;
2+
3+
import lombok.RequiredArgsConstructor;
4+
import org.springframework.data.domain.PageRequest;
5+
import org.springframework.stereotype.Service;
6+
import org.springframework.transaction.annotation.Transactional;
7+
import targeter.aim.domain.challenge.dto.RankDto;
8+
import targeter.aim.domain.challenge.entity.ChallengeMode;
9+
import targeter.aim.domain.challenge.repository.ChallengeRankQueryRepository;
10+
import targeter.aim.domain.file.dto.FileDto;
11+
import targeter.aim.domain.user.entity.User;
12+
import targeter.aim.domain.user.repository.UserRepository;
13+
14+
import java.util.*;
15+
import java.util.stream.Collectors;
16+
17+
@Service
18+
@RequiredArgsConstructor
19+
public class ChallengeRankService {
20+
21+
private final UserRepository userRepository;
22+
private final ChallengeRankQueryRepository challengeRankQueryRepository;
23+
24+
@Transactional(readOnly = true)
25+
public List<RankDto.Top20RankResponse> getTop20Rank() {
26+
27+
List<User> users = userRepository.findAllByOrderByLevelDescIdAsc(PageRequest.of(0, 20)).getContent();
28+
if (users.isEmpty()) {
29+
return List.of();
30+
}
31+
32+
List<Long> userIds = users.stream().map(User::getId).toList();
33+
34+
Map<Long, ChallengeRankQueryRepository.Record> overallMap =
35+
challengeRankQueryRepository.calcRecordByUsers(userIds, null);
36+
37+
Map<Long, ChallengeRankQueryRepository.Record> soloMap =
38+
challengeRankQueryRepository.calcRecordByUsers(userIds, ChallengeMode.SOLO);
39+
40+
Map<Long, ChallengeRankQueryRepository.Record> vsMap =
41+
challengeRankQueryRepository.calcRecordByUsers(userIds, ChallengeMode.VS);
42+
43+
Map<Long, Integer> rankByUserId = new HashMap<>();
44+
for (int i = 0; i < users.size(); i++) {
45+
rankByUserId.put(users.get(i).getId(), i + 1);
46+
}
47+
48+
return users.stream()
49+
.map(u -> {
50+
int rank = rankByUserId.get(u.getId());
51+
52+
RankDto.UserInfo userInfo = RankDto.UserInfo.builder()
53+
.nickname(u.getNickname())
54+
.profileImage(u.getProfileImage() == null ? null : FileDto.FileResponse.from(u.getProfileImage()))
55+
.tier(RankDto.TierResponse.builder().name(u.getTier().getName()).build())
56+
.level(u.getLevel())
57+
.build();
58+
59+
RankDto.ChallengeRecord allRecord = toRecordDto(overallMap.get(u.getId()));
60+
61+
// 1~3위만 solo/vs 포함
62+
RankDto.ChallengeRecord soloRecord = (rank <= 3) ? toRecordDto(soloMap.get(u.getId())) : null;
63+
RankDto.ChallengeRecord vsRecord = (rank <= 3) ? toRecordDto(vsMap.get(u.getId())) : null;
64+
65+
return RankDto.Top20RankResponse.builder()
66+
.rank(rank)
67+
.userId(u.getId())
68+
.userInfo(userInfo)
69+
.allRecord(allRecord)
70+
.soloRecord(soloRecord)
71+
.vsRecord(vsRecord)
72+
.build();
73+
})
74+
.collect(Collectors.toList());
75+
}
76+
77+
private RankDto.ChallengeRecord toRecordDto(ChallengeRankQueryRepository.Record record) {
78+
if (record == null) {
79+
return RankDto.ChallengeRecord.builder()
80+
.attemptCount(0)
81+
.successCount(0)
82+
.failCount(0)
83+
.successRate(0)
84+
.build();
85+
}
86+
87+
long attempt = record.attempt();
88+
long success = record.success();
89+
long fail = attempt - success;
90+
int successRate = attempt == 0 ? 0 : (int) Math.round((success * 100.0) / attempt);
91+
92+
return RankDto.ChallengeRecord.builder()
93+
.attemptCount(attempt)
94+
.successCount(success)
95+
.failCount(fail)
96+
.successRate(successRate)
97+
.build();
98+
}
99+
}

0 commit comments

Comments
 (0)