Skip to content

Commit

Permalink
feat: 내기록 탭 > 완료 미션 목록 조회 API (#118)
Browse files Browse the repository at this point in the history
* feat: 내기록 완료 미션 조회 API 구현

* refactor: 코드 정리

* refactor: record -> history 로 변경

* test: 테스트코드 추가

* chore: 코드 정리

* chore: swagger 정리

* refactor: 리뷰 반영
  • Loading branch information
songyi00 authored Jan 18, 2025
1 parent dfe8a31 commit 009ff84
Show file tree
Hide file tree
Showing 10 changed files with 373 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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<MissionMember> completedMissionMembers = getCompletedMissionMembers(memberId, pageRequest);
Map<Long, List<MissionMember>> missionMemberMap = completedMissionMembers.stream()
.collect(Collectors.groupingBy(missionMember -> missionMember.getMission().getId()));

// 2. 완료한 미션 목록 조회
List<Long> completedMissionIds = getCompletedMissionIds(completedMissionMembers);
List<Mission> missions = missionRepository.findAllById(completedMissionIds);

// 3. 미션 별 인증 목록 조회
Map<Long, List<MissionVerification>> 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<Long> getCompletedMissionIds(final List<MissionMember> completedMissionMembers) {
return completedMissionMembers.stream()
.map(MissionMember::getId)
.collect(Collectors.toList());
}

private Map<Long, List<MissionVerification>> getMissionVerificationMap(
final Long memberId,
final List<Long> completedMissionIds
) {
return missionVerificationRepository.findByMemberIdAndMissionIdIn(memberId, completedMissionIds)
.stream()
.collect(Collectors.groupingBy(missionVerification -> missionVerification.getMission().getId()));
}

private List<MissionMember> getCompletedMissionMembers(final Long memberId, final PageRequest pageRequest) {
return missionMemberRepository.findByMemberIdAndMissionStatus(
memberId,
MissionStatus.COMPLETED,
pageRequest
).stream()
.toList();
}
}
Original file line number Diff line number Diff line change
@@ -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<CompletedMission> 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<MissionVerification> missionVerifications,
final List<MissionMember> 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()
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MissionVerification> 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();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -34,6 +35,14 @@ public interface MissionMemberRepository extends JpaRepository<MissionMember, Lo

Optional<MissionMember> findTop1ByMemberIdOrderByUpdatedAtDesc(final Long memberId);

List<MissionMember> 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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ public interface MissionVerificationRepository extends JpaRepository<MissionVeri
+ " WHERE mb.id = :memberId AND ms.id = :missionId AND DATE(mv.createdAt) = :date")
Optional<MissionVerification> findByMemberIdAndMissionIdAndDate(Long memberId, Long missionId, LocalDate date);

List<MissionVerification> findByMemberIdAndMissionIdIn(final Long memberId, final List<Long> 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));
Expand Down
Original file line number Diff line number Diff line change
@@ -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<HistoryResponse.CompletedMissionWrapper> 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);
}
}
Original file line number Diff line number Diff line change
@@ -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<HistoryResponse.CompletedMissionWrapper> getMyMissionHistories(
@Parameter(in = ParameterIn.HEADER, hidden = true)
final Long memberId,
@Schema(description = "페이지 번호 (default:0)")
final Integer page,
@Schema(description = "페이지 사이즈 (default:30)")
final Integer pageSize
);
}
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading

0 comments on commit 009ff84

Please sign in to comment.