diff --git a/src/main/java/com/challenge/api/service/challenge/ChallengeService.java b/src/main/java/com/challenge/api/service/challenge/ChallengeService.java index c1647bc..3ee7e9b 100644 --- a/src/main/java/com/challenge/api/service/challenge/ChallengeService.java +++ b/src/main/java/com/challenge/api/service/challenge/ChallengeService.java @@ -60,7 +60,7 @@ public List getChallenges(Member member, ChallengeQueryServic @Transactional public ChallengeResponse createChallenge(Member member, ChallengeCreateServiceRequest request, - LocalDateTime startDateTime) { + LocalDateTime startDateTime) { // validation categoryValidator.categoryExistsBy(request.getCategoryId()); @@ -72,8 +72,7 @@ public ChallengeResponse createChallenge(Member member, ChallengeCreateServiceRe } @Transactional - public ChallengeResponse achieveChallenge(Member member, Long challengeId, - ChallengeAchieveServiceRequest request) { + public ChallengeResponse achieveChallenge(Member member, Long challengeId, ChallengeAchieveServiceRequest request) { // validation validateChallengeAchieveOrCancel(member, challengeId, request.getAchieveDate()); diff --git a/src/main/java/com/challenge/config/ClockConfig.java b/src/main/java/com/challenge/config/ClockConfig.java new file mode 100644 index 0000000..ded2703 --- /dev/null +++ b/src/main/java/com/challenge/config/ClockConfig.java @@ -0,0 +1,16 @@ +package com.challenge.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; + +@Configuration +public class ClockConfig { + + @Bean + public Clock clock() { + return Clock.systemDefaultZone(); + } + +} diff --git a/src/main/java/com/challenge/domain/challenge/Challenge.java b/src/main/java/com/challenge/domain/challenge/Challenge.java index acbbb65..ce4cd70 100644 --- a/src/main/java/com/challenge/domain/challenge/Challenge.java +++ b/src/main/java/com/challenge/domain/challenge/Challenge.java @@ -123,6 +123,14 @@ public void addRecord(ChallengeRecord challengeRecord) { this.challengeRecords.add(challengeRecord); } + public void success() { + this.status = ChallengeStatus.SUCCEED; + } + + public void expire() { + this.status = ChallengeStatus.EXPIRED; + } + public void delete() { this.status = ChallengeStatus.REMOVED; } diff --git a/src/main/java/com/challenge/domain/challenge/ChallengeQueryRepository.java b/src/main/java/com/challenge/domain/challenge/ChallengeQueryRepository.java index b4e92ad..ab607ed 100644 --- a/src/main/java/com/challenge/domain/challenge/ChallengeQueryRepository.java +++ b/src/main/java/com/challenge/domain/challenge/ChallengeQueryRepository.java @@ -43,6 +43,13 @@ public boolean existsDuplicateRecordBy(Challenge challenge, LocalDate successDat return count != null && count > 0; } + // 진행중인 챌린지 조회 + public List findOngoingChallengesBy() { + return queryFactory.selectFrom(challenge) + .where(challenge.status.eq(ChallengeStatus.ONGOING)) + .fetch(); + } + // 진행중인 챌린지 수 조회 public Long countOngoingChallengesBy(Member member) { return queryFactory.select(challenge.count()) diff --git a/src/main/java/com/challenge/domain/challengeRecord/ChallengeRecordRepository.java b/src/main/java/com/challenge/domain/challengeRecord/ChallengeRecordRepository.java index 853f707..b31434b 100644 --- a/src/main/java/com/challenge/domain/challengeRecord/ChallengeRecordRepository.java +++ b/src/main/java/com/challenge/domain/challengeRecord/ChallengeRecordRepository.java @@ -2,6 +2,10 @@ import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + public interface ChallengeRecordRepository extends JpaRepository { + List findAllByChallengeId(Long challengeId); + } diff --git a/src/main/java/com/challenge/scheduler/ChallengeScheduler.java b/src/main/java/com/challenge/scheduler/ChallengeScheduler.java new file mode 100644 index 0000000..e598c26 --- /dev/null +++ b/src/main/java/com/challenge/scheduler/ChallengeScheduler.java @@ -0,0 +1,78 @@ +package com.challenge.scheduler; + +import com.challenge.domain.challenge.Challenge; +import com.challenge.domain.challenge.ChallengeQueryRepository; +import com.challenge.domain.challenge.ChallengeRepository; +import com.challenge.domain.challengeRecord.ChallengeRecord; +import com.challenge.domain.challengeRecord.ChallengeRecordRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Profile; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Profile({"dev", "test"}) +@Slf4j +@Component +@RequiredArgsConstructor +public class ChallengeScheduler { + + private final Clock clock; + + private final ChallengeQueryRepository challengeQueryRepository; + + private final ChallengeRepository challengeRepository; + private final ChallengeRecordRepository challengeRecordRepository; + + /** + * 챌린지 상태 업데이트 스케줄러 - 매일 00시 00분 00초 실행 + */ + @Scheduled(cron = "0 0 0 * * *") + public void updateChallengeStatus() { + LocalDateTime now = LocalDateTime.now(clock); + + // '진행 중' 상태의 챌린지 가져오기 + List ongoingChallenges = challengeQueryRepository.findOngoingChallengesBy(); + + for (Challenge challenge : ongoingChallenges) { + // 종료 시간이 현재 시간보다 이후인 경우 상태 업데이트를 건너뜀 + if (challenge.getEndDateTime().isAfter(now)) { + continue; + } + + // 챌린지 기록에서 해당 챌린지 ID에 대한 기록 가져오기 + List records = challengeRecordRepository.findAllByChallengeId(challenge.getId()); + + // 날짜별로 마지막 상태가 '달성'인 기록 카운트 + long successCount = calculateSuccessCount(records); + + // 상태 변경: 목표 횟수 이상이면 성공, 아니면 기간 만료 + if (successCount >= challenge.getTotalGoalCount()) { + challenge.success(); + } else { + challenge.expire(); + } + + // 변경된 상태 저장 + challengeRepository.save(challenge); + } + } + + private long calculateSuccessCount(List records) { + return records.stream() + .collect(Collectors.groupingBy(ChallengeRecord::getRecordDate, Collectors.toList())) + .values().stream() + .filter(recordList -> { + // 해당 날짜의 마지막 기록이 '달성' 상태인지 확인 + ChallengeRecord lastRecord = recordList.get(recordList.size() - 1); + return lastRecord.isSucceed(); + }) + .count(); + } + +} diff --git a/src/test/java/com/challenge/domain/challenge/ChallengeQueryRepositoryTest.java b/src/test/java/com/challenge/domain/challenge/ChallengeQueryRepositoryTest.java index 2476b6e..68f85dc 100644 --- a/src/test/java/com/challenge/domain/challenge/ChallengeQueryRepositoryTest.java +++ b/src/test/java/com/challenge/domain/challenge/ChallengeQueryRepositoryTest.java @@ -134,6 +134,33 @@ void existsDuplicateRecordBy() { assertThat(result).isTrue(); } + @DisplayName("진행중인 챌린지를 조회한다.") + @Test + void findOngoingChallengesBy() { + // given + Member member = createMember(); + memberRepository.save(member); + + Category category = createCategory(); + categoryRepository.save(category); + + Challenge challenge1 = createChallenge(member, category, 1, "제목1", ONGOING, + LocalDateTime.of(2024, 10, 1, 12, 30, 59)); + Challenge challenge2 = createChallenge(member, category, 2, "제목2", ONGOING, + LocalDateTime.of(2024, 11, 11, 14, 0, 0)); + Challenge challenge3 = createChallenge(member, category, 3, "제목3", SUCCEED, + LocalDateTime.of(2024, 12, 23, 0, 0, 0)); + Challenge challenge4 = createChallenge(member, category, 1, "제목4", REMOVED, + LocalDateTime.of(2025, 1, 1, 0, 0, 0)); + challengeRepository.saveAll(List.of(challenge1, challenge2, challenge3, challenge4)); + + // when + List ongoingChallenges = challengeQueryRepository.findOngoingChallengesBy(); + + // then + assertThat(ongoingChallenges).hasSize(2); + } + @DisplayName("진행중인 챌린지 수를 조회한다.") @Test void countOngoingChallenges() { diff --git a/src/test/java/com/challenge/domain/challengeRecord/ChallengeRecordRepositoryTest.java b/src/test/java/com/challenge/domain/challengeRecord/ChallengeRecordRepositoryTest.java new file mode 100644 index 0000000..639bcd7 --- /dev/null +++ b/src/test/java/com/challenge/domain/challengeRecord/ChallengeRecordRepositoryTest.java @@ -0,0 +1,142 @@ +package com.challenge.domain.challengeRecord; + +import com.challenge.domain.category.Category; +import com.challenge.domain.category.CategoryRepository; +import com.challenge.domain.challenge.Challenge; +import com.challenge.domain.challenge.ChallengeRepository; +import com.challenge.domain.challenge.ChallengeStatus; +import com.challenge.domain.job.Job; +import com.challenge.domain.job.JobRepository; +import com.challenge.domain.member.Gender; +import com.challenge.domain.member.JobYear; +import com.challenge.domain.member.LoginType; +import com.challenge.domain.member.Member; +import com.challenge.domain.member.MemberRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import static com.challenge.domain.challenge.ChallengeStatus.ONGOING; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.groups.Tuple.tuple; + +@SpringBootTest +@ActiveProfiles("test") +class ChallengeRecordRepositoryTest { + + @Autowired + private ChallengeRepository challengeRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private CategoryRepository categoryRepository; + + @Autowired + private JobRepository jobRepository; + + @Autowired + private ChallengeRecordRepository challengeRecordRepository; + + @AfterEach + void tearDown() { + challengeRecordRepository.deleteAllInBatch(); + challengeRepository.deleteAllInBatch(); + memberRepository.deleteAllInBatch(); + categoryRepository.deleteAllInBatch(); + jobRepository.deleteAllInBatch(); + } + + @DisplayName("특정 챌린지 ID에 대한 모든 챌린지 기록을 조회한다.") + @Test + void findAllByChallengeId() { + // given + Category category = createCategory(); + categoryRepository.save(category); + + Member member = createMember(); + memberRepository.save(member); + + Challenge challenge1 = createChallenge(member, category, 1, "제목1", ONGOING, + LocalDateTime.of(2024, 10, 1, 12, 30, 59)); + Challenge challenge2 = createChallenge(member, category, 2, "제목2", ONGOING, + LocalDateTime.of(2024, 11, 16, 14, 0, 0)); + Challenge challenge3 = createChallenge(member, category, 3, "제목3", ONGOING, + LocalDateTime.of(2024, 12, 1, 0, 0, 0)); + challengeRepository.saveAll(List.of(challenge1, challenge2, challenge3)); + + LocalDate currentDate = LocalDate.of(2025, 1, 18); + ChallengeRecord challengeRecord1 = createRecord(challenge1, currentDate, true); + ChallengeRecord challengeRecord2 = createRecord(challenge1, currentDate, false); + ChallengeRecord challengeRecord3 = createRecord(challenge1, currentDate, true); + challengeRecordRepository.saveAll(List.of(challengeRecord1, challengeRecord2, challengeRecord3)); + + // when + List challengeRecords = challengeRecordRepository.findAllByChallengeId(challenge1.getId()); + + // then + assertThat(challengeRecords).hasSize(3) + .extracting("recordDate", "isSucceed") + .containsExactlyInAnyOrder( + tuple(currentDate, true), + tuple(currentDate, false), + tuple(currentDate, true) + ); + } + + private Member createMember() { + Job job = Job.builder() + .code("1") + .description("1") + .build(); + jobRepository.save(job); + + return Member.builder() + .socialId(1L) + .email("eamil") + .loginType(LoginType.KAKAO) + .nickname("nickname") + .birth(LocalDate.of(2000, 1, 1)) + .gender(Gender.MALE) + .jobYear(JobYear.LT_1Y) + .job(job) + .build(); + } + + private Challenge createChallenge(Member member, Category category, int durationInWeeks, String title, + ChallengeStatus status, LocalDateTime startDateTime) { + return Challenge.builder() + .member(member) + .category(category) + .durationInWeeks(durationInWeeks) + .title(title) + .color("#30B0C7") + .status(status) + .weeklyGoalCount(1) + .startDateTime(startDateTime) + .build(); + } + + private Category createCategory() { + return Category.builder() + .name("카테고리") + .build(); + } + + private ChallengeRecord createRecord(Challenge challenge, LocalDate currentDate, boolean isSucceed) { + return ChallengeRecord.builder() + .challenge(challenge) + .recordDate(currentDate) + .isSucceed(isSucceed) + .build(); + } + +} diff --git a/src/test/java/com/challenge/scheduler/ChallengeSchedulerTest.java b/src/test/java/com/challenge/scheduler/ChallengeSchedulerTest.java new file mode 100644 index 0000000..e052308 --- /dev/null +++ b/src/test/java/com/challenge/scheduler/ChallengeSchedulerTest.java @@ -0,0 +1,178 @@ +package com.challenge.scheduler; + +import com.challenge.domain.category.Category; +import com.challenge.domain.category.CategoryRepository; +import com.challenge.domain.challenge.Challenge; +import com.challenge.domain.challenge.ChallengeRepository; +import com.challenge.domain.challenge.ChallengeStatus; +import com.challenge.domain.challengeRecord.ChallengeRecord; +import com.challenge.domain.challengeRecord.ChallengeRecordRepository; +import com.challenge.domain.job.Job; +import com.challenge.domain.job.JobRepository; +import com.challenge.domain.member.Gender; +import com.challenge.domain.member.JobYear; +import com.challenge.domain.member.LoginType; +import com.challenge.domain.member.Member; +import com.challenge.domain.member.MemberRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +@SpringBootTest +@ActiveProfiles("test") +class ChallengeSchedulerTest { + + @MockitoBean + private Clock clock; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private CategoryRepository categoryRepository; + + @Autowired + private JobRepository jobRepository; + + @Autowired + private ChallengeRepository challengeRepository; + + @Autowired + private ChallengeRecordRepository challengeRecordRepository; + + @Autowired + private ChallengeScheduler challengeScheduler; + + @AfterEach + void tearDown() { + challengeRecordRepository.deleteAllInBatch(); + challengeRepository.deleteAllInBatch(); + memberRepository.deleteAllInBatch(); + categoryRepository.deleteAllInBatch(); + jobRepository.deleteAllInBatch(); + } + + @Test + @DisplayName("챌린지 상태 업데이트 스케줄러 테스트") + void updateChallengeStatus() { + // given + // 1월 9일 00:00:00으로 고정된 Clock 설정 + LocalDateTime fixedDateTime = LocalDateTime.of(2025, 1, 9, 0, 0, 0); + Clock fixedClock = Clock.fixed( + fixedDateTime.atZone(ZoneId.systemDefault()).toInstant(), + ZoneId.systemDefault()); + given(clock.instant()).willReturn(fixedClock.instant()); + given(clock.getZone()).willReturn(fixedClock.getZone()); + + Member member = createMember(); + memberRepository.save(member); + + Category category = createCategory(); + categoryRepository.save(category); + + // 챌린지 생성 + // 종료 기간은 1월 8일 23시 59분 59초 + Challenge challenge1 = createChallenge(member, category, 3, + LocalDateTime.of(2025, 1, 1, 12, 30, 59)); + Challenge challenge2 = createChallenge(member, category, 2, + LocalDateTime.of(2025, 1, 1, 14, 0, 0)); + challengeRepository.saveAll(List.of(challenge1, challenge2)); + + // 기록 생성 + ChallengeRecord challengeRecord1 = createRecord(challenge1, true, + LocalDate.of(2025, 1, 2)); + ChallengeRecord challengeRecord2 = createRecord(challenge1, false, + LocalDate.of(2024, 1, 3)); + ChallengeRecord challengeRecord3 = createRecord(challenge1, true, + LocalDate.of(2024, 1, 4)); + + ChallengeRecord challengeRecord4 = createRecord(challenge2, true, + LocalDate.of(2025, 1, 2)); + ChallengeRecord challengeRecord5 = createRecord(challenge2, false, + LocalDate.of(2025, 1, 2)); + ChallengeRecord challengeRecord6 = createRecord(challenge2, true, + LocalDate.of(2025, 1, 3)); + ChallengeRecord challengeRecord7 = createRecord(challenge2, true, + LocalDate.of(2025, 1, 4)); + challengeRecordRepository.saveAll( + List.of( + challengeRecord1, challengeRecord2, challengeRecord3, + challengeRecord4, challengeRecord5, challengeRecord6, challengeRecord7 + ) + ); + + // when + challengeScheduler.updateChallengeStatus(); + + // then + assertThat(challengeRepository.findById(challenge1.getId()).get().getStatus()) + .isEqualTo(ChallengeStatus.EXPIRED); + assertThat(challengeRepository.findById(challenge2.getId()).get().getStatus()) + .isEqualTo(ChallengeStatus.SUCCEED); + } + + private Member createMember() { + Job job = Job.builder() + .code("1") + .description("1") + .build(); + jobRepository.save(job); + + return Member.builder() + .socialId(1L) + .email("eamil") + .loginType(LoginType.KAKAO) + .nickname("nickname") + .birth(LocalDate.of(2000, 1, 1)) + .gender(Gender.MALE) + .jobYear(JobYear.LT_1Y) + .job(job) + .build(); + } + + private Category createCategory() { + return Category.builder() + .name("category") + .build(); + } + + private Challenge createChallenge(Member member, Category category, int weeklyGoalCount, + LocalDateTime startDateTime) { + return Challenge.builder() + .member(member) + .category(category) + .durationInWeeks(1) + .title("제목") + .status(ChallengeStatus.ONGOING) + .color("#30B0C7") + .weeklyGoalCount(weeklyGoalCount) + .startDateTime(startDateTime) + .build(); + } + + private ChallengeRecord createRecord(Challenge challenge, boolean isSucceed, LocalDate currentDate) { + return ChallengeRecord.builder() + .challenge(challenge) + .recordDate(currentDate) + .isSucceed(isSucceed) + .build(); + } + +} +