From 009ff84d2cb78de1f001ede4ac6bda5cfbbb6196 Mon Sep 17 00:00:00 2001 From: Songyi Kim <52441906+songyi00@users.noreply.github.com> Date: Sat, 18 Jan 2025 17:08:25 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=82=B4=EA=B8=B0=EB=A1=9D=20=ED=83=AD?= =?UTF-8?q?=20>=20=EC=99=84=EB=A3=8C=20=EB=AF=B8=EC=85=98=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=20(#118)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 내기록 완료 미션 조회 API 구현 * refactor: 코드 정리 * refactor: record -> history 로 변경 * test: 테스트코드 추가 * chore: 코드 정리 * chore: swagger 정리 * refactor: 리뷰 반영 --- .../application/history/HistoryService.java | 85 ++++++++++++++++++ .../history/dto/response/HistoryResponse.java | 88 +++++++++++++++++++ .../goalpanzi/domain/mission/MemberRanks.java | 7 ++ .../domain/mission/MissionVerifications.java | 26 ++++++ .../repository/MissionMemberRepository.java | 9 ++ .../MissionVerificationRepository.java | 2 + .../history/HistoryController.java | 30 +++++++ .../history/HistoryControllerDocs.java | 23 +++++ .../history/HistoryServiceTest.java | 82 +++++++++++++++++ .../fixture/MissionVerificationFixture.java | 21 +++++ 10 files changed, 373 insertions(+) create mode 100644 src/main/java/com/nexters/goalpanzi/application/history/HistoryService.java create mode 100644 src/main/java/com/nexters/goalpanzi/application/history/dto/response/HistoryResponse.java create mode 100644 src/main/java/com/nexters/goalpanzi/domain/mission/MissionVerifications.java create mode 100644 src/main/java/com/nexters/goalpanzi/presentation/history/HistoryController.java create mode 100644 src/main/java/com/nexters/goalpanzi/presentation/history/HistoryControllerDocs.java create mode 100644 src/test/java/com/nexters/goalpanzi/application/history/HistoryServiceTest.java create mode 100644 src/test/java/com/nexters/goalpanzi/fixture/MissionVerificationFixture.java diff --git a/src/main/java/com/nexters/goalpanzi/application/history/HistoryService.java b/src/main/java/com/nexters/goalpanzi/application/history/HistoryService.java new file mode 100644 index 00000000..ddd9398b --- /dev/null +++ b/src/main/java/com/nexters/goalpanzi/application/history/HistoryService.java @@ -0,0 +1,85 @@ +package com.nexters.goalpanzi.application.history; + +import com.nexters.goalpanzi.application.history.dto.response.HistoryResponse; +import com.nexters.goalpanzi.domain.mission.Mission; +import com.nexters.goalpanzi.domain.mission.MissionMember; +import com.nexters.goalpanzi.domain.mission.MissionStatus; +import com.nexters.goalpanzi.domain.mission.MissionVerification; +import com.nexters.goalpanzi.domain.mission.repository.MissionMemberRepository; +import com.nexters.goalpanzi.domain.mission.repository.MissionRepository; +import com.nexters.goalpanzi.domain.mission.repository.MissionVerificationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Transactional(readOnly = true) +@RequiredArgsConstructor +@Service +public class HistoryService { + + private final MissionRepository missionRepository; + private final MissionMemberRepository missionMemberRepository; + private final MissionVerificationRepository missionVerificationRepository; + + public HistoryResponse.CompletedMissionWrapper getMissionHistories( + final Long memberId, + final PageRequest pageRequest + ) { + // 1. 완료한 미션 참여 멤버 목록 조회 + List completedMissionMembers = getCompletedMissionMembers(memberId, pageRequest); + Map> missionMemberMap = completedMissionMembers.stream() + .collect(Collectors.groupingBy(missionMember -> missionMember.getMission().getId())); + + // 2. 완료한 미션 목록 조회 + List completedMissionIds = getCompletedMissionIds(completedMissionMembers); + List missions = missionRepository.findAllById(completedMissionIds); + + // 3. 미션 별 인증 목록 조회 + Map> missionVerificationMap = getMissionVerificationMap(memberId, completedMissionIds); + + // 4. 완료한 미션 총 개수 조회 + var totalCount = missionMemberRepository.countByMemberIdAndMissionStatus(memberId, MissionStatus.COMPLETED); + + var histories = missions.stream() + .map(mission -> HistoryResponse.CompletedMission.of( + memberId, mission, missionVerificationMap.get(mission.getId()), missionMemberMap.get(mission.getId()))) + .sorted(Comparator.comparing(HistoryResponse.CompletedMission::missionEndDate).reversed()) + .toList(); + + return new HistoryResponse.CompletedMissionWrapper( + totalCount, + histories + ); + + } + + private List getCompletedMissionIds(final List completedMissionMembers) { + return completedMissionMembers.stream() + .map(MissionMember::getId) + .collect(Collectors.toList()); + } + + private Map> getMissionVerificationMap( + final Long memberId, + final List completedMissionIds + ) { + return missionVerificationRepository.findByMemberIdAndMissionIdIn(memberId, completedMissionIds) + .stream() + .collect(Collectors.groupingBy(missionVerification -> missionVerification.getMission().getId())); + } + + private List getCompletedMissionMembers(final Long memberId, final PageRequest pageRequest) { + return missionMemberRepository.findByMemberIdAndMissionStatus( + memberId, + MissionStatus.COMPLETED, + pageRequest + ).stream() + .toList(); + } +} diff --git a/src/main/java/com/nexters/goalpanzi/application/history/dto/response/HistoryResponse.java b/src/main/java/com/nexters/goalpanzi/application/history/dto/response/HistoryResponse.java new file mode 100644 index 00000000..4ee55793 --- /dev/null +++ b/src/main/java/com/nexters/goalpanzi/application/history/dto/response/HistoryResponse.java @@ -0,0 +1,88 @@ +package com.nexters.goalpanzi.application.history.dto.response; + +import com.nexters.goalpanzi.domain.mission.MemberRanks; +import com.nexters.goalpanzi.domain.mission.Mission; +import com.nexters.goalpanzi.domain.mission.MissionMember; +import com.nexters.goalpanzi.domain.mission.MissionVerification; +import com.nexters.goalpanzi.domain.mission.MissionVerifications; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class HistoryResponse { + + public record CompletedMissionWrapper( + @Schema(description = "총 개수", requiredMode = Schema.RequiredMode.REQUIRED) + Long totalCount, + @Schema(description = "내 미션 히스토리 목록", requiredMode = Schema.RequiredMode.REQUIRED) + List resultList + ) { + } + + @Builder + public record CompletedMission( + @Schema(description = "미션 ID", requiredMode = Schema.RequiredMode.REQUIRED) + Long missionId, + @Schema(description = "미션 이름 (목표 행동)", requiredMode = Schema.RequiredMode.REQUIRED) + String description, + @Schema(description = "미션 시작 날짜", requiredMode = Schema.RequiredMode.REQUIRED) + LocalDateTime missionStartDate, + @Schema(description = "미션 종료 날짜", requiredMode = Schema.RequiredMode.REQUIRED) + LocalDateTime missionEndDate, + @Schema(description = "나의 인증 횟수", requiredMode = Schema.RequiredMode.REQUIRED) + Integer myVerificationCount, + @Schema(description = "총 인증 가능 횟수 (보드칸 개수)", requiredMode = Schema.RequiredMode.REQUIRED) + Integer totalVerificationCount, + @Schema(description = "최종 등수", requiredMode = Schema.RequiredMode.REQUIRED) + Integer rank, + @Schema(description = "나의 미션 인증 사진(랜덤)", requiredMode = Schema.RequiredMode.NOT_REQUIRED) + String randomImageUrl, + @Schema(description = "참여 인원 수", requiredMode = Schema.RequiredMode.REQUIRED) + Integer memberCount + ) { + + public static CompletedMission of( + final Mission mission, + final String randomImageUrl, + final Integer myVerificationCount, + final Integer memberCount, + final Integer rank + ) { + return CompletedMission.builder() + .missionId(mission.getId()) + .description(mission.getDescription()) + .missionStartDate(mission.getMissionStartDate()) + .missionEndDate(mission.getMissionEndDate()) + .totalVerificationCount(mission.getBoardCount()) + .myVerificationCount(myVerificationCount) + .memberCount(memberCount) + .randomImageUrl(randomImageUrl) + .rank(rank) + .build(); + } + + + public static CompletedMission of( + final Long memberId, + final Mission mission, + final List missionVerifications, + final List missionMembers + ) { + MissionVerifications verifications = new MissionVerifications(missionVerifications); + MemberRanks memberRanks = MemberRanks.from(missionMembers); + + return CompletedMission.of( + mission, + verifications.getRandomImageUrl(), + verifications.count(), + missionMembers.size(), + memberRanks.getRankByMemberId(memberId).rank() + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/nexters/goalpanzi/domain/mission/MemberRanks.java b/src/main/java/com/nexters/goalpanzi/domain/mission/MemberRanks.java index 233146fb..935c3629 100644 --- a/src/main/java/com/nexters/goalpanzi/domain/mission/MemberRanks.java +++ b/src/main/java/com/nexters/goalpanzi/domain/mission/MemberRanks.java @@ -49,6 +49,13 @@ public MemberRank getRankByMember(final Member member) { .orElseThrow(() -> new NoSuchElementException("No rank found for member " + member.getId())); } + public MemberRank getRankByMemberId(final Long memberId) { + return memberRanks.stream() + .filter(memberRank -> memberRank.member().getId().equals(memberId)) + .findFirst() + .orElseThrow(() -> new NoSuchElementException("No rank found for member " + memberId)); + } + @Override public boolean equals(final Object o) { if (this == o) return true; diff --git a/src/main/java/com/nexters/goalpanzi/domain/mission/MissionVerifications.java b/src/main/java/com/nexters/goalpanzi/domain/mission/MissionVerifications.java new file mode 100644 index 00000000..98ce2abe --- /dev/null +++ b/src/main/java/com/nexters/goalpanzi/domain/mission/MissionVerifications.java @@ -0,0 +1,26 @@ +package com.nexters.goalpanzi.domain.mission; + +import lombok.RequiredArgsConstructor; + +import java.util.List; +import java.util.Random; + +@RequiredArgsConstructor +public class MissionVerifications { + + private static final Random RANDOM = new Random(); + private final List missionVerifications; + + public String getRandomImageUrl() { + if (missionVerifications.isEmpty()) { + return null; + } + + int randomIndex = RANDOM.nextInt(missionVerifications.size()); + return missionVerifications.get(randomIndex).getImageUrl(); + } + + public int count() { + return missionVerifications.size(); + } +} diff --git a/src/main/java/com/nexters/goalpanzi/domain/mission/repository/MissionMemberRepository.java b/src/main/java/com/nexters/goalpanzi/domain/mission/repository/MissionMemberRepository.java index 81d4ab7a..e2ade52a 100644 --- a/src/main/java/com/nexters/goalpanzi/domain/mission/repository/MissionMemberRepository.java +++ b/src/main/java/com/nexters/goalpanzi/domain/mission/repository/MissionMemberRepository.java @@ -4,6 +4,7 @@ import com.nexters.goalpanzi.domain.mission.MissionStatus; import com.nexters.goalpanzi.exception.ErrorCode; import com.nexters.goalpanzi.exception.NotFoundException; +import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -34,6 +35,14 @@ public interface MissionMemberRepository extends JpaRepository findTop1ByMemberIdOrderByUpdatedAtDesc(final Long memberId); + List findByMemberIdAndMissionStatus( + final Long memberId, + final MissionStatus status, + final Pageable pageable + ); + + Long countByMemberIdAndMissionStatus(final Long memberId, final MissionStatus status); + default MissionMember getMissionMember(final Long memberId, final Long missionId) { return findByMemberIdAndMissionId(memberId, missionId) .orElseThrow(() -> new NotFoundException(ErrorCode.NOT_JOINED_MISSION_MEMBER)); diff --git a/src/main/java/com/nexters/goalpanzi/domain/mission/repository/MissionVerificationRepository.java b/src/main/java/com/nexters/goalpanzi/domain/mission/repository/MissionVerificationRepository.java index cf9ee4cc..232b7b45 100644 --- a/src/main/java/com/nexters/goalpanzi/domain/mission/repository/MissionVerificationRepository.java +++ b/src/main/java/com/nexters/goalpanzi/domain/mission/repository/MissionVerificationRepository.java @@ -28,6 +28,8 @@ public interface MissionVerificationRepository extends JpaRepository findByMemberIdAndMissionIdAndDate(Long memberId, Long missionId, LocalDate date); + List findByMemberIdAndMissionIdIn(final Long memberId, final List missionIds); + default MissionVerification getMyVerification(final Long memberId, final Long missionId, final Integer boardNumber) { return findByMemberIdAndMissionIdAndBoardNumber(memberId, missionId, boardNumber) .orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_VERIFICATION)); diff --git a/src/main/java/com/nexters/goalpanzi/presentation/history/HistoryController.java b/src/main/java/com/nexters/goalpanzi/presentation/history/HistoryController.java new file mode 100644 index 00000000..ac57c4ef --- /dev/null +++ b/src/main/java/com/nexters/goalpanzi/presentation/history/HistoryController.java @@ -0,0 +1,30 @@ +package com.nexters.goalpanzi.presentation.history; + +import com.nexters.goalpanzi.application.history.HistoryService; +import com.nexters.goalpanzi.application.history.dto.response.HistoryResponse; +import com.nexters.goalpanzi.common.argumentresolver.LoginMemberId; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class HistoryController implements HistoryControllerDocs { + + private final HistoryService historyService; + + @Override + @GetMapping("/api/missions/history") + public ResponseEntity getMyMissionHistories( + @LoginMemberId final Long memberId, + @RequestParam(defaultValue = "0") final Integer page, + @RequestParam(defaultValue = "30") final Integer pageSize + ) { + var result = historyService.getMissionHistories(memberId, PageRequest.of(page, pageSize)); + + return ResponseEntity.ok(result); + } +} diff --git a/src/main/java/com/nexters/goalpanzi/presentation/history/HistoryControllerDocs.java b/src/main/java/com/nexters/goalpanzi/presentation/history/HistoryControllerDocs.java new file mode 100644 index 00000000..56af9b6e --- /dev/null +++ b/src/main/java/com/nexters/goalpanzi/presentation/history/HistoryControllerDocs.java @@ -0,0 +1,23 @@ +package com.nexters.goalpanzi.presentation.history; + +import com.nexters.goalpanzi.application.history.dto.response.HistoryResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; + +@Tag(name = "내 기록 (히스토리)") +public interface HistoryControllerDocs { + + @Operation(summary = "내 완료 미션 기록 조회") + ResponseEntity getMyMissionHistories( + @Parameter(in = ParameterIn.HEADER, hidden = true) + final Long memberId, + @Schema(description = "페이지 번호 (default:0)") + final Integer page, + @Schema(description = "페이지 사이즈 (default:30)") + final Integer pageSize + ); +} diff --git a/src/test/java/com/nexters/goalpanzi/application/history/HistoryServiceTest.java b/src/test/java/com/nexters/goalpanzi/application/history/HistoryServiceTest.java new file mode 100644 index 00000000..4a935fed --- /dev/null +++ b/src/test/java/com/nexters/goalpanzi/application/history/HistoryServiceTest.java @@ -0,0 +1,82 @@ +package com.nexters.goalpanzi.application.history; + +import com.nexters.goalpanzi.config.redis.RedisInitializer; +import com.nexters.goalpanzi.domain.member.Member; +import com.nexters.goalpanzi.domain.member.SocialType; +import com.nexters.goalpanzi.domain.mission.Mission; +import com.nexters.goalpanzi.domain.mission.MissionMember; +import com.nexters.goalpanzi.domain.mission.MissionStatus; +import com.nexters.goalpanzi.domain.mission.MissionVerification; +import com.nexters.goalpanzi.domain.mission.repository.MissionMemberRepository; +import com.nexters.goalpanzi.domain.mission.repository.MissionRepository; +import com.nexters.goalpanzi.domain.mission.repository.MissionVerificationRepository; +import com.nexters.goalpanzi.fixture.MemberFixture; +import com.nexters.goalpanzi.fixture.MissionFixture; +import com.nexters.goalpanzi.fixture.MissionVerificationFixture; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.data.domain.PageRequest; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +@SpringBootTest +@ContextConfiguration( + initializers = {RedisInitializer.class} +) +class HistoryServiceTest { + + @Autowired + private HistoryService historyService; + + @MockBean + private MissionRepository missionRepository; + + @MockBean + private MissionMemberRepository missionMemberRepository; + + @MockBean + private MissionVerificationRepository missionVerificationRepository; + + @Test + void 완료된_미션_기록을_조회한다() { + // given + Member member = Member.socialLogin("abc", "email", SocialType.GOOGLE); + Mission mission = MissionFixture.create(); + + int VERIFICATION_COUNT = 1; + Long COMPLETED_MISSION_COUNT = 1L; + MissionMember missionMember = new MissionMember(member, mission, VERIFICATION_COUNT); + MissionVerification missionVerification = MissionVerificationFixture.create(mission, member, MissionFixture.UPLOADED_IMAGE_URL, mission.getBoardCount()); + ReflectionTestUtils.setField(mission, "id", 1L); + ReflectionTestUtils.setField(member, "id", 1L); + + when(missionRepository.findAllById(any())).thenReturn(List.of(mission)); + when(missionMemberRepository.findByMemberIdAndMissionStatus(any(), eq(MissionStatus.COMPLETED), any())).thenReturn(List.of(missionMember)); + when(missionMemberRepository.countByMemberIdAndMissionStatus(any(), eq(MissionStatus.COMPLETED))).thenReturn(COMPLETED_MISSION_COUNT); + when(missionVerificationRepository.findByMemberIdAndMissionIdIn(any(), any())).thenReturn(List.of(missionVerification)); + + // when + var actual = historyService.getMissionHistories( + MemberFixture.MEMBER_ID, + PageRequest.ofSize(10) + ); + + // then + assertAll( + () -> assertThat(actual.totalCount()).isEqualTo(COMPLETED_MISSION_COUNT), + () -> assertThat(actual.resultList()).hasSize(1), + () -> assertThat(actual.resultList().getFirst().myVerificationCount()).isEqualTo(VERIFICATION_COUNT), + () -> assertThat(actual.resultList().getFirst().totalVerificationCount()).isEqualTo(mission.getBoardCount()), + () -> assertThat(actual.resultList().getFirst().rank()).isEqualTo(1)); + } +} \ No newline at end of file diff --git a/src/test/java/com/nexters/goalpanzi/fixture/MissionVerificationFixture.java b/src/test/java/com/nexters/goalpanzi/fixture/MissionVerificationFixture.java new file mode 100644 index 00000000..a81ae037 --- /dev/null +++ b/src/test/java/com/nexters/goalpanzi/fixture/MissionVerificationFixture.java @@ -0,0 +1,21 @@ +package com.nexters.goalpanzi.fixture; + +import com.nexters.goalpanzi.domain.member.Member; +import com.nexters.goalpanzi.domain.mission.Mission; +import com.nexters.goalpanzi.domain.mission.MissionVerification; + +public class MissionVerificationFixture { + public static MissionVerification create( + Mission mission, + Member member, + String imageUrl, + Integer boardCount + ) { + return new MissionVerification( + member, + mission, + imageUrl, + boardCount + ); + } +}