Skip to content

Commit 6f95b10

Browse files
authored
feat: 봉사 시간 기준 랭킹 조회 (#124)
* feat(VolunteerProfileQueryController): 엔드포인트 수정 - 스웨거 태그 수정 * feat(VolunteerRepository): 봉사 시간 내림차순 봉사자 랭킹 조회 추가 - mapper 추가 (VolunteerOverviewForRankingByHours) - 사용하지 않는 deleteAllInBatch 삭제 * feat(VolunteerRankingResponseDto): 봉사자 랭킹 응답 DTO 추가 - VolunteerRankingResponseDto 생성 및 VolunteerOverview 내 포함 - VolunteerOverviewForRankingByHours(mapper)를 기반으로 DTO 변환 로직 추가 * feat(VolunteerQueryService): 봉사 시간 내림차순 봉사자 랭킹 조회 추가 * feat(VolunteerRankingQueryController): 봉사 시간 내림차순 봉사자 랭킹 조회 컨트롤러 추가 * feat(Volunteer): 봉사 시간, 봉사 횟수 업데이트 추가 * feat(VolunteerRepository): deleteAllInBatch 추가 * test(VolunteerRepository): 봉사 시간 기준 봉사자 조회 - 상위 4명 조회 - 봉사자 없으면 빈 리스트 반환 - 봉사자 4명 미만이면 전체 조회 * test(VolunteerQueryService): 봉사 시간 기준 봉사자 조회 - 상위 4명 조회 - 봉사자 없으면 빈 리스트 반환 * chore: 불필요한 개행 삭제, 필요한 개행 추가 * fix: 삭제된 괄호 추가 * test(VolunteerQueryService): deleteAllInBatch 추가 - getRankingByHours_noVolunteers 오류 해결 * refactor: unusedImport 삭제, 변수 명 수정
1 parent 5c958ed commit 6f95b10

File tree

11 files changed

+264
-24
lines changed

11 files changed

+264
-24
lines changed

src/main/java/com/somemore/volunteer/controller/VolunteerProfileQueryController.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@
1919
@RestController
2020
@Slf4j
2121
@RequiredArgsConstructor
22-
@RequestMapping("/api/profile")
23-
@Tag(name = "GET Volunteer", description = "봉사자 조회")
22+
@RequestMapping("/api/volunteer/profile")
23+
@Tag(name = "GET Volunteer Profile", description = "봉사자 조회")
2424
public class VolunteerProfileQueryController {
2525

2626
private final VolunteerQueryUseCase volunteerQueryUseCase;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package com.somemore.volunteer.controller;
2+
3+
import com.somemore.global.common.response.ApiResponse;
4+
import com.somemore.volunteer.dto.response.VolunteerRankingResponseDto;
5+
import com.somemore.volunteer.usecase.VolunteerQueryUseCase;
6+
import io.swagger.v3.oas.annotations.Operation;
7+
import io.swagger.v3.oas.annotations.tags.Tag;
8+
import lombok.RequiredArgsConstructor;
9+
import lombok.extern.slf4j.Slf4j;
10+
import org.springframework.web.bind.annotation.GetMapping;
11+
import org.springframework.web.bind.annotation.RequestMapping;
12+
import org.springframework.web.bind.annotation.RestController;
13+
14+
@RestController
15+
@Slf4j
16+
@RequiredArgsConstructor
17+
@RequestMapping("/api/volunteer/ranking")
18+
@Tag(name = "GET Volunteer ranking", description = "봉사자 랭킹 조회")
19+
public class VolunteerRankingQueryController {
20+
21+
private final VolunteerQueryUseCase volunteerQueryUseCase;
22+
23+
@Operation(summary = "봉사 시간 랭킹 조회", description = "봉사 시간 내림차순 4명 조회")
24+
@GetMapping("/hours")
25+
public ApiResponse<VolunteerRankingResponseDto> getRankingByHours() {
26+
27+
return ApiResponse.ok(
28+
200,
29+
volunteerQueryUseCase.getRankingByHours(),
30+
"랭킹(시간) 조회 성공");
31+
}
32+
}

src/main/java/com/somemore/volunteer/domain/Volunteer.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ public void updateWith(VolunteerProfileUpdateRequestDto dto, String imgUrl) {
6565
this.imgUrl = imgUrl;
6666
}
6767

68+
public void updateVolunteerStats(int hours, int count) {
69+
this.totalVolunteerHours += hours;
70+
this.totalVolunteerCount += count;
71+
}
72+
6873
@Builder
6974
private Volunteer(
7075
OAuthProvider oauthProvider,
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package com.somemore.volunteer.dto.response;
2+
3+
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
4+
import com.fasterxml.jackson.databind.annotation.JsonNaming;
5+
import com.somemore.volunteer.repository.mapper.VolunteerOverviewForRankingByHours;
6+
import io.swagger.v3.oas.annotations.media.Schema;
7+
import lombok.Builder;
8+
9+
import java.util.List;
10+
11+
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
12+
@Builder
13+
public record VolunteerRankingResponseDto(
14+
@Schema(description = "랭킹에 포함된 봉사자 리스트")
15+
List<VolunteerOverview> rankings
16+
) {
17+
@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class)
18+
@Builder
19+
public record VolunteerOverview(
20+
@Schema(description = "봉사자 ID", example = "uuid-uuid-uuid-uuid")
21+
String volunteerId,
22+
23+
@Schema(description = "봉사자 닉네임", example = "길동이")
24+
String nickname,
25+
26+
@Schema(description = "봉사자 이미지 URL", example = "http://example.com/image.jpg")
27+
String imgUrl,
28+
29+
@Schema(description = "봉사자 소개글", example = "안녕하세요! 저는 자원봉사자 홍길동입니다.")
30+
String introduce,
31+
32+
@Schema(description = "봉사자 티어", example = "red")
33+
String tier,
34+
35+
@Schema(description = "봉사자의 총 봉사 시간", example = "120")
36+
Integer totalVolunteerHours
37+
) {
38+
private static VolunteerOverview from(VolunteerOverviewForRankingByHours source) {
39+
return VolunteerOverview.builder()
40+
.volunteerId(source.volunteerId().toString())
41+
.nickname(source.nickname())
42+
.imgUrl(source.imgUrl())
43+
.introduce(source.introduce())
44+
.tier(source.tier().name())
45+
.totalVolunteerHours(source.totalVolunteerHours())
46+
.build();
47+
}
48+
}
49+
50+
public static VolunteerRankingResponseDto from(List<VolunteerOverviewForRankingByHours> sources) {
51+
return VolunteerRankingResponseDto.builder()
52+
.rankings(sources.stream()
53+
.map(VolunteerOverview::from)
54+
.toList())
55+
.build();
56+
}
57+
}

src/main/java/com/somemore/volunteer/repository/VolunteerRepository.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package com.somemore.volunteer.repository;
22

33
import com.somemore.volunteer.domain.Volunteer;
4+
import com.somemore.volunteer.repository.mapper.VolunteerOverviewForRankingByHours;
5+
import org.springframework.stereotype.Repository;
6+
47
import java.util.List;
58
import java.util.Optional;
69
import java.util.UUID;
7-
import org.springframework.stereotype.Repository;
810

911
@Repository
1012
public interface VolunteerRepository {
@@ -17,6 +19,8 @@ public interface VolunteerRepository {
1719

1820
String findNicknameById(UUID id);
1921

22+
List<VolunteerOverviewForRankingByHours> findRankingByVolunteerHours();
23+
2024
void deleteAllInBatch();
2125

2226
List<Volunteer> findAllByIds(List<UUID> volunteerIds);

src/main/java/com/somemore/volunteer/repository/VolunteerRepositoryImpl.java

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
package com.somemore.volunteer.repository;
22

33
import com.querydsl.core.types.Path;
4+
import com.querydsl.core.types.Projections;
45
import com.querydsl.core.types.dsl.BooleanExpression;
56
import com.querydsl.jpa.impl.JPAQueryFactory;
67
import com.somemore.volunteer.domain.QVolunteer;
78
import com.somemore.volunteer.domain.Volunteer;
9+
import com.somemore.volunteer.repository.mapper.VolunteerOverviewForRankingByHours;
10+
import lombok.RequiredArgsConstructor;
11+
import org.springframework.stereotype.Repository;
12+
813
import java.util.List;
914
import java.util.Optional;
1015
import java.util.UUID;
11-
import lombok.RequiredArgsConstructor;
12-
import org.springframework.stereotype.Repository;
1316

1417
@RequiredArgsConstructor
1518
@Repository
@@ -41,6 +44,24 @@ public String findNicknameById(UUID id) {
4144
.orElse(null);
4245
}
4346

47+
@Override
48+
public List<VolunteerOverviewForRankingByHours> findRankingByVolunteerHours() {
49+
return queryFactory
50+
.select(Projections.constructor(VolunteerOverviewForRankingByHours.class,
51+
volunteer.id,
52+
volunteer.nickname,
53+
volunteer.imgUrl,
54+
volunteer.introduce,
55+
volunteer.tier,
56+
volunteer.totalVolunteerHours
57+
))
58+
.from(volunteer)
59+
.where(isNotDeleted())
60+
.orderBy(volunteer.totalVolunteerHours.desc())
61+
.limit(4)
62+
.fetch();
63+
}
64+
4465
@Override
4566
public void deleteAllInBatch() {
4667
volunteerJpaRepository.deleteAllInBatch();
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.somemore.volunteer.repository.mapper;
2+
3+
import com.somemore.volunteer.domain.Tier;
4+
5+
import java.util.UUID;
6+
7+
public record VolunteerOverviewForRankingByHours(
8+
UUID volunteerId,
9+
String nickname,
10+
String imgUrl,
11+
String introduce,
12+
Tier tier,
13+
Integer totalVolunteerHours
14+
) {
15+
}

src/main/java/com/somemore/volunteer/service/VolunteerQueryService.java

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,25 @@
11
package com.somemore.volunteer.service;
22

3-
import static com.somemore.global.exception.ExceptionMessage.NOT_EXISTS_VOLUNTEER;
4-
53
import com.somemore.facade.validator.VolunteerDetailAccessValidator;
64
import com.somemore.global.exception.BadRequestException;
75
import com.somemore.volunteer.domain.Volunteer;
86
import com.somemore.volunteer.domain.VolunteerDetail;
97
import com.somemore.volunteer.dto.response.VolunteerProfileResponseDto;
8+
import com.somemore.volunteer.dto.response.VolunteerRankingResponseDto;
109
import com.somemore.volunteer.repository.VolunteerDetailRepository;
1110
import com.somemore.volunteer.repository.VolunteerRepository;
11+
import com.somemore.volunteer.repository.mapper.VolunteerOverviewForRankingByHours;
1212
import com.somemore.volunteer.usecase.VolunteerQueryUseCase;
13-
import java.util.List;
14-
import java.util.UUID;
1513
import lombok.RequiredArgsConstructor;
1614
import lombok.extern.slf4j.Slf4j;
1715
import org.springframework.stereotype.Service;
1816
import org.springframework.transaction.annotation.Transactional;
1917

18+
import java.util.List;
19+
import java.util.UUID;
20+
21+
import static com.somemore.global.exception.ExceptionMessage.NOT_EXISTS_VOLUNTEER;
22+
2023
@Slf4j
2124
@Service
2225
@RequiredArgsConstructor
@@ -46,7 +49,7 @@ public VolunteerProfileResponseDto getVolunteerProfile(UUID volunteerId) {
4649

4750
@Override
4851
public VolunteerProfileResponseDto getVolunteerDetailedProfile(UUID volunteerId,
49-
UUID centerId) {
52+
UUID centerId) {
5053
volunteerDetailAccessValidator.validateByCenterId(centerId, volunteerId);
5154

5255
return VolunteerProfileResponseDto.from(
@@ -73,6 +76,12 @@ public String getNicknameById(UUID id) {
7376
return nickname;
7477
}
7578

79+
@Override
80+
public VolunteerRankingResponseDto getRankingByHours() {
81+
List<VolunteerOverviewForRankingByHours> rankingByVolunteerHours = volunteerRepository.findRankingByVolunteerHours();
82+
return VolunteerRankingResponseDto.from(rankingByVolunteerHours);
83+
}
84+
7685
@Override
7786
public List<Volunteer> getAllByIds(List<UUID> volunteerIds) {
7887
return volunteerRepository.findAllByIds(volunteerIds);
@@ -81,7 +90,6 @@ public List<Volunteer> getAllByIds(List<UUID> volunteerIds) {
8190
private Volunteer findVolunteer(UUID volunteerId) {
8291
return volunteerRepository.findById(volunteerId)
8392
.orElseThrow(() -> new BadRequestException(NOT_EXISTS_VOLUNTEER));
84-
8593
}
8694

8795
private VolunteerDetail findVolunteerDetail(UUID volunteerId) {

src/main/java/com/somemore/volunteer/usecase/VolunteerQueryUseCase.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import com.somemore.volunteer.domain.Volunteer;
44
import com.somemore.volunteer.dto.response.VolunteerProfileResponseDto;
5+
import com.somemore.volunteer.dto.response.VolunteerRankingResponseDto;
6+
57
import java.util.List;
68
import java.util.UUID;
79

@@ -17,5 +19,7 @@ public interface VolunteerQueryUseCase {
1719

1820
String getNicknameById(UUID id);
1921

22+
VolunteerRankingResponseDto getRankingByHours();
23+
2024
List<Volunteer> getAllByIds(List<UUID> volunteerIds);
2125
}

src/test/java/com/somemore/volunteer/repository/VolunteerRepositoryTest.java

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,21 @@
11
package com.somemore.volunteer.repository;
22

3-
import static org.assertj.core.api.Assertions.assertThat;
4-
53
import com.somemore.IntegrationTestSupport;
64
import com.somemore.auth.oauth.OAuthProvider;
75
import com.somemore.volunteer.domain.Volunteer;
8-
import java.util.List;
9-
import java.util.Optional;
10-
import java.util.UUID;
6+
import com.somemore.volunteer.repository.mapper.VolunteerOverviewForRankingByHours;
117
import org.junit.jupiter.api.BeforeEach;
128
import org.junit.jupiter.api.DisplayName;
139
import org.junit.jupiter.api.Test;
1410
import org.springframework.beans.factory.annotation.Autowired;
1511
import org.springframework.transaction.annotation.Transactional;
1612

13+
import java.util.List;
14+
import java.util.Optional;
15+
import java.util.UUID;
16+
17+
import static org.assertj.core.api.Assertions.assertThat;
18+
1719
@Transactional
1820
class VolunteerRepositoryTest extends IntegrationTestSupport {
1921

@@ -77,6 +79,59 @@ void findByOauthId() {
7779
assertThat(foundVolunteer.get().getNickname()).isEqualTo(volunteer.getNickname());
7880
}
7981

82+
@DisplayName("봉사 시간 기준 상위 4명을 조회한다.")
83+
@Test
84+
void findRankingByVolunteerHours_top4() {
85+
// given
86+
for (int i = 1; i <= 5; i++) {
87+
createVolunteerAndUpdateVolunteerStats(i);
88+
}
89+
90+
// when
91+
List<VolunteerOverviewForRankingByHours> rankings = volunteerRepository.findRankingByVolunteerHours();
92+
93+
// then
94+
assertThat(rankings).hasSize(4);
95+
assertThat(rankings.get(0).totalVolunteerHours()).isGreaterThan(rankings.get(1).totalVolunteerHours());
96+
}
97+
98+
@DisplayName("등록된 봉사자가 없는 경우 빈 리스트를 반환한다.")
99+
@Test
100+
void findRankingByVolunteerHours_noVolunteers() {
101+
// given
102+
volunteerRepository.deleteAllInBatch();
103+
104+
// when
105+
List<VolunteerOverviewForRankingByHours> rankings = volunteerRepository.findRankingByVolunteerHours();
106+
107+
// then
108+
assertThat(rankings).isEmpty();
109+
}
110+
111+
@DisplayName("등록된 봉사자가 4명 이하인 경우 전체 봉사자를 반환한다.")
112+
@Test
113+
void findRankingByVolunteerHours_lessThan4Volunteers() {
114+
// given
115+
volunteerRepository.deleteAllInBatch();
116+
117+
for (int i = 1; i <= 3; i++) {
118+
createVolunteerAndUpdateVolunteerStats(i);
119+
}
120+
121+
// when
122+
List<VolunteerOverviewForRankingByHours> rankings = volunteerRepository.findRankingByVolunteerHours();
123+
124+
// then
125+
assertThat(rankings).hasSize(3);
126+
assertThat(rankings.get(0).totalVolunteerHours()).isGreaterThan(rankings.get(1).totalVolunteerHours());
127+
}
128+
129+
private void createVolunteerAndUpdateVolunteerStats(int i) {
130+
Volunteer newVolunteer = Volunteer.createDefault(OAuthProvider.NAVER, "oauth-id-" + i);
131+
newVolunteer.updateVolunteerStats(i * 10, i);
132+
volunteerRepository.save(newVolunteer);
133+
}
134+
80135
@DisplayName("아이디 리스트로 봉사자를 조회할 수있다.")
81136
@Test
82137
void findAllByIds() {

0 commit comments

Comments
 (0)