diff --git a/src/main/java/com/somemore/volunteer/controller/VolunteerProfileQueryController.java b/src/main/java/com/somemore/volunteer/controller/VolunteerProfileQueryController.java index 7ff1459c8..a1ddeaf0e 100644 --- a/src/main/java/com/somemore/volunteer/controller/VolunteerProfileQueryController.java +++ b/src/main/java/com/somemore/volunteer/controller/VolunteerProfileQueryController.java @@ -19,8 +19,8 @@ @RestController @Slf4j @RequiredArgsConstructor -@RequestMapping("/api/profile") -@Tag(name = "GET Volunteer", description = "봉사자 조회") +@RequestMapping("/api/volunteer/profile") +@Tag(name = "GET Volunteer Profile", description = "봉사자 조회") public class VolunteerProfileQueryController { private final VolunteerQueryUseCase volunteerQueryUseCase; diff --git a/src/main/java/com/somemore/volunteer/controller/VolunteerRankingQueryController.java b/src/main/java/com/somemore/volunteer/controller/VolunteerRankingQueryController.java new file mode 100644 index 000000000..6ec7f64d4 --- /dev/null +++ b/src/main/java/com/somemore/volunteer/controller/VolunteerRankingQueryController.java @@ -0,0 +1,32 @@ +package com.somemore.volunteer.controller; + +import com.somemore.global.common.response.ApiResponse; +import com.somemore.volunteer.dto.response.VolunteerRankingResponseDto; +import com.somemore.volunteer.usecase.VolunteerQueryUseCase; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@Slf4j +@RequiredArgsConstructor +@RequestMapping("/api/volunteer/ranking") +@Tag(name = "GET Volunteer ranking", description = "봉사자 랭킹 조회") +public class VolunteerRankingQueryController { + + private final VolunteerQueryUseCase volunteerQueryUseCase; + + @Operation(summary = "봉사 시간 랭킹 조회", description = "봉사 시간 내림차순 4명 조회") + @GetMapping("/hours") + public ApiResponse getRankingByHours() { + + return ApiResponse.ok( + 200, + volunteerQueryUseCase.getRankingByHours(), + "랭킹(시간) 조회 성공"); + } +} diff --git a/src/main/java/com/somemore/volunteer/domain/Volunteer.java b/src/main/java/com/somemore/volunteer/domain/Volunteer.java index e98872fa1..deb9a5abc 100644 --- a/src/main/java/com/somemore/volunteer/domain/Volunteer.java +++ b/src/main/java/com/somemore/volunteer/domain/Volunteer.java @@ -65,6 +65,11 @@ public void updateWith(VolunteerProfileUpdateRequestDto dto, String imgUrl) { this.imgUrl = imgUrl; } + public void updateVolunteerStats(int hours, int count) { + this.totalVolunteerHours += hours; + this.totalVolunteerCount += count; + } + @Builder private Volunteer( OAuthProvider oauthProvider, diff --git a/src/main/java/com/somemore/volunteer/dto/response/VolunteerRankingResponseDto.java b/src/main/java/com/somemore/volunteer/dto/response/VolunteerRankingResponseDto.java new file mode 100644 index 000000000..dbdb690d2 --- /dev/null +++ b/src/main/java/com/somemore/volunteer/dto/response/VolunteerRankingResponseDto.java @@ -0,0 +1,57 @@ +package com.somemore.volunteer.dto.response; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.somemore.volunteer.repository.mapper.VolunteerOverviewForRankingByHours; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@Builder +public record VolunteerRankingResponseDto( + @Schema(description = "랭킹에 포함된 봉사자 리스트") + List rankings +) { + @JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) + @Builder + public record VolunteerOverview( + @Schema(description = "봉사자 ID", example = "uuid-uuid-uuid-uuid") + String volunteerId, + + @Schema(description = "봉사자 닉네임", example = "길동이") + String nickname, + + @Schema(description = "봉사자 이미지 URL", example = "http://example.com/image.jpg") + String imgUrl, + + @Schema(description = "봉사자 소개글", example = "안녕하세요! 저는 자원봉사자 홍길동입니다.") + String introduce, + + @Schema(description = "봉사자 티어", example = "red") + String tier, + + @Schema(description = "봉사자의 총 봉사 시간", example = "120") + Integer totalVolunteerHours + ) { + private static VolunteerOverview from(VolunteerOverviewForRankingByHours source) { + return VolunteerOverview.builder() + .volunteerId(source.volunteerId().toString()) + .nickname(source.nickname()) + .imgUrl(source.imgUrl()) + .introduce(source.introduce()) + .tier(source.tier().name()) + .totalVolunteerHours(source.totalVolunteerHours()) + .build(); + } + } + + public static VolunteerRankingResponseDto from(List sources) { + return VolunteerRankingResponseDto.builder() + .rankings(sources.stream() + .map(VolunteerOverview::from) + .toList()) + .build(); + } +} diff --git a/src/main/java/com/somemore/volunteer/repository/VolunteerRepository.java b/src/main/java/com/somemore/volunteer/repository/VolunteerRepository.java index b099617af..4710eb824 100644 --- a/src/main/java/com/somemore/volunteer/repository/VolunteerRepository.java +++ b/src/main/java/com/somemore/volunteer/repository/VolunteerRepository.java @@ -1,10 +1,12 @@ package com.somemore.volunteer.repository; import com.somemore.volunteer.domain.Volunteer; +import com.somemore.volunteer.repository.mapper.VolunteerOverviewForRankingByHours; +import org.springframework.stereotype.Repository; + import java.util.List; import java.util.Optional; import java.util.UUID; -import org.springframework.stereotype.Repository; @Repository public interface VolunteerRepository { @@ -17,6 +19,8 @@ public interface VolunteerRepository { String findNicknameById(UUID id); + List findRankingByVolunteerHours(); + void deleteAllInBatch(); List findAllByIds(List volunteerIds); diff --git a/src/main/java/com/somemore/volunteer/repository/VolunteerRepositoryImpl.java b/src/main/java/com/somemore/volunteer/repository/VolunteerRepositoryImpl.java index a78b7c7f5..6b9fdf178 100644 --- a/src/main/java/com/somemore/volunteer/repository/VolunteerRepositoryImpl.java +++ b/src/main/java/com/somemore/volunteer/repository/VolunteerRepositoryImpl.java @@ -1,15 +1,18 @@ package com.somemore.volunteer.repository; import com.querydsl.core.types.Path; +import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import com.somemore.volunteer.domain.QVolunteer; import com.somemore.volunteer.domain.Volunteer; +import com.somemore.volunteer.repository.mapper.VolunteerOverviewForRankingByHours; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + import java.util.List; import java.util.Optional; import java.util.UUID; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; @RequiredArgsConstructor @Repository @@ -41,6 +44,24 @@ public String findNicknameById(UUID id) { .orElse(null); } + @Override + public List findRankingByVolunteerHours() { + return queryFactory + .select(Projections.constructor(VolunteerOverviewForRankingByHours.class, + volunteer.id, + volunteer.nickname, + volunteer.imgUrl, + volunteer.introduce, + volunteer.tier, + volunteer.totalVolunteerHours + )) + .from(volunteer) + .where(isNotDeleted()) + .orderBy(volunteer.totalVolunteerHours.desc()) + .limit(4) + .fetch(); + } + @Override public void deleteAllInBatch() { volunteerJpaRepository.deleteAllInBatch(); diff --git a/src/main/java/com/somemore/volunteer/repository/mapper/VolunteerOverviewForRankingByHours.java b/src/main/java/com/somemore/volunteer/repository/mapper/VolunteerOverviewForRankingByHours.java new file mode 100644 index 000000000..48f77c212 --- /dev/null +++ b/src/main/java/com/somemore/volunteer/repository/mapper/VolunteerOverviewForRankingByHours.java @@ -0,0 +1,15 @@ +package com.somemore.volunteer.repository.mapper; + +import com.somemore.volunteer.domain.Tier; + +import java.util.UUID; + +public record VolunteerOverviewForRankingByHours( + UUID volunteerId, + String nickname, + String imgUrl, + String introduce, + Tier tier, + Integer totalVolunteerHours +) { +} diff --git a/src/main/java/com/somemore/volunteer/service/VolunteerQueryService.java b/src/main/java/com/somemore/volunteer/service/VolunteerQueryService.java index c62bc8133..ab08cd948 100644 --- a/src/main/java/com/somemore/volunteer/service/VolunteerQueryService.java +++ b/src/main/java/com/somemore/volunteer/service/VolunteerQueryService.java @@ -1,22 +1,25 @@ package com.somemore.volunteer.service; -import static com.somemore.global.exception.ExceptionMessage.NOT_EXISTS_VOLUNTEER; - import com.somemore.facade.validator.VolunteerDetailAccessValidator; import com.somemore.global.exception.BadRequestException; import com.somemore.volunteer.domain.Volunteer; import com.somemore.volunteer.domain.VolunteerDetail; import com.somemore.volunteer.dto.response.VolunteerProfileResponseDto; +import com.somemore.volunteer.dto.response.VolunteerRankingResponseDto; import com.somemore.volunteer.repository.VolunteerDetailRepository; import com.somemore.volunteer.repository.VolunteerRepository; +import com.somemore.volunteer.repository.mapper.VolunteerOverviewForRankingByHours; import com.somemore.volunteer.usecase.VolunteerQueryUseCase; -import java.util.List; -import java.util.UUID; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.UUID; + +import static com.somemore.global.exception.ExceptionMessage.NOT_EXISTS_VOLUNTEER; + @Slf4j @Service @RequiredArgsConstructor @@ -46,7 +49,7 @@ public VolunteerProfileResponseDto getVolunteerProfile(UUID volunteerId) { @Override public VolunteerProfileResponseDto getVolunteerDetailedProfile(UUID volunteerId, - UUID centerId) { + UUID centerId) { volunteerDetailAccessValidator.validateByCenterId(centerId, volunteerId); return VolunteerProfileResponseDto.from( @@ -73,6 +76,12 @@ public String getNicknameById(UUID id) { return nickname; } + @Override + public VolunteerRankingResponseDto getRankingByHours() { + List rankingByVolunteerHours = volunteerRepository.findRankingByVolunteerHours(); + return VolunteerRankingResponseDto.from(rankingByVolunteerHours); + } + @Override public List getAllByIds(List volunteerIds) { return volunteerRepository.findAllByIds(volunteerIds); @@ -81,7 +90,6 @@ public List getAllByIds(List volunteerIds) { private Volunteer findVolunteer(UUID volunteerId) { return volunteerRepository.findById(volunteerId) .orElseThrow(() -> new BadRequestException(NOT_EXISTS_VOLUNTEER)); - } private VolunteerDetail findVolunteerDetail(UUID volunteerId) { diff --git a/src/main/java/com/somemore/volunteer/usecase/VolunteerQueryUseCase.java b/src/main/java/com/somemore/volunteer/usecase/VolunteerQueryUseCase.java index c5b9111f7..bb85aa23a 100644 --- a/src/main/java/com/somemore/volunteer/usecase/VolunteerQueryUseCase.java +++ b/src/main/java/com/somemore/volunteer/usecase/VolunteerQueryUseCase.java @@ -2,6 +2,8 @@ import com.somemore.volunteer.domain.Volunteer; import com.somemore.volunteer.dto.response.VolunteerProfileResponseDto; +import com.somemore.volunteer.dto.response.VolunteerRankingResponseDto; + import java.util.List; import java.util.UUID; @@ -17,5 +19,7 @@ public interface VolunteerQueryUseCase { String getNicknameById(UUID id); + VolunteerRankingResponseDto getRankingByHours(); + List getAllByIds(List volunteerIds); } diff --git a/src/test/java/com/somemore/volunteer/repository/VolunteerRepositoryTest.java b/src/test/java/com/somemore/volunteer/repository/VolunteerRepositoryTest.java index 28c823244..d517ad410 100644 --- a/src/test/java/com/somemore/volunteer/repository/VolunteerRepositoryTest.java +++ b/src/test/java/com/somemore/volunteer/repository/VolunteerRepositoryTest.java @@ -1,19 +1,21 @@ package com.somemore.volunteer.repository; -import static org.assertj.core.api.Assertions.assertThat; - import com.somemore.IntegrationTestSupport; import com.somemore.auth.oauth.OAuthProvider; import com.somemore.volunteer.domain.Volunteer; -import java.util.List; -import java.util.Optional; -import java.util.UUID; +import com.somemore.volunteer.repository.mapper.VolunteerOverviewForRankingByHours; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + @Transactional class VolunteerRepositoryTest extends IntegrationTestSupport { @@ -77,6 +79,59 @@ void findByOauthId() { assertThat(foundVolunteer.get().getNickname()).isEqualTo(volunteer.getNickname()); } + @DisplayName("봉사 시간 기준 상위 4명을 조회한다.") + @Test + void findRankingByVolunteerHours_top4() { + // given + for (int i = 1; i <= 5; i++) { + createVolunteerAndUpdateVolunteerStats(i); + } + + // when + List rankings = volunteerRepository.findRankingByVolunteerHours(); + + // then + assertThat(rankings).hasSize(4); + assertThat(rankings.get(0).totalVolunteerHours()).isGreaterThan(rankings.get(1).totalVolunteerHours()); + } + + @DisplayName("등록된 봉사자가 없는 경우 빈 리스트를 반환한다.") + @Test + void findRankingByVolunteerHours_noVolunteers() { + // given + volunteerRepository.deleteAllInBatch(); + + // when + List rankings = volunteerRepository.findRankingByVolunteerHours(); + + // then + assertThat(rankings).isEmpty(); + } + + @DisplayName("등록된 봉사자가 4명 이하인 경우 전체 봉사자를 반환한다.") + @Test + void findRankingByVolunteerHours_lessThan4Volunteers() { + // given + volunteerRepository.deleteAllInBatch(); + + for (int i = 1; i <= 3; i++) { + createVolunteerAndUpdateVolunteerStats(i); + } + + // when + List rankings = volunteerRepository.findRankingByVolunteerHours(); + + // then + assertThat(rankings).hasSize(3); + assertThat(rankings.get(0).totalVolunteerHours()).isGreaterThan(rankings.get(1).totalVolunteerHours()); + } + + private void createVolunteerAndUpdateVolunteerStats(int i) { + Volunteer newVolunteer = Volunteer.createDefault(OAuthProvider.NAVER, "oauth-id-" + i); + newVolunteer.updateVolunteerStats(i * 10, i); + volunteerRepository.save(newVolunteer); + } + @DisplayName("아이디 리스트로 봉사자를 조회할 수있다.") @Test void findAllByIds() { diff --git a/src/test/java/com/somemore/volunteer/service/VolunteerQueryServiceTest.java b/src/test/java/com/somemore/volunteer/service/VolunteerQueryServiceTest.java index d9e36cbba..855dcdfc6 100644 --- a/src/test/java/com/somemore/volunteer/service/VolunteerQueryServiceTest.java +++ b/src/test/java/com/somemore/volunteer/service/VolunteerQueryServiceTest.java @@ -1,11 +1,5 @@ package com.somemore.volunteer.service; -import static com.somemore.global.exception.ExceptionMessage.NOT_EXISTS_VOLUNTEER; -import static com.somemore.global.exception.ExceptionMessage.UNAUTHORIZED_VOLUNTEER_DETAIL; -import static com.somemore.volunteer.domain.Volunteer.createDefault; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - import com.somemore.IntegrationTestSupport; import com.somemore.auth.oauth.OAuthProvider; import com.somemore.global.exception.BadRequestException; @@ -13,15 +7,23 @@ import com.somemore.volunteer.domain.VolunteerDetail; import com.somemore.volunteer.dto.request.VolunteerRegisterRequestDto; import com.somemore.volunteer.dto.response.VolunteerProfileResponseDto; +import com.somemore.volunteer.dto.response.VolunteerRankingResponseDto; import com.somemore.volunteer.repository.VolunteerDetailRepository; import com.somemore.volunteer.repository.VolunteerRepository; -import java.util.List; -import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.UUID; + +import static com.somemore.global.exception.ExceptionMessage.NOT_EXISTS_VOLUNTEER; +import static com.somemore.global.exception.ExceptionMessage.UNAUTHORIZED_VOLUNTEER_DETAIL; +import static com.somemore.volunteer.domain.Volunteer.createDefault; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + @Transactional class VolunteerQueryServiceTest extends IntegrationTestSupport { @@ -154,6 +156,43 @@ void getVolunteerDetailedProfile() { .hasMessage(UNAUTHORIZED_VOLUNTEER_DETAIL.getMessage()); } + @DisplayName("봉사 시간 기준 상위 4명의 랭킹을 조회한다.") + @Test + void getRankingByHours() { + // given + for (int i = 1; i <= 5; i++) { + Volunteer volunteer = Volunteer.createDefault(oAuthProvider, "oauth-id-" + i); + volunteer.updateVolunteerStats(i * 10, i); + volunteerRepository.save(volunteer); + } + + // when + VolunteerRankingResponseDto response = volunteerQueryService.getRankingByHours(); + + // then + assertThat(response).isNotNull(); + assertThat(response.rankings()).hasSize(4); + + List hours = response.rankings().stream() + .map(VolunteerRankingResponseDto.VolunteerOverview::totalVolunteerHours) + .toList(); + assertThat(hours).isSortedAccordingTo((a, b) -> b - a); + } + + @DisplayName("등록된 봉사자가 없는 경우 빈 랭킹 리스트를 반환한다.") + @Test + void getRankingByHours_noVolunteers() { + // given + volunteerRepository.deleteAllInBatch(); + + // when + VolunteerRankingResponseDto response = volunteerQueryService.getRankingByHours(); + + // then + assertThat(response).isNotNull(); + assertThat(response.rankings()).isEmpty(); + } + @DisplayName("아이디 리스트로 봉사자를 조회할 수있다.") @Test void findAllByIds() {