diff --git a/src/main/java/com/somemore/SomemoreApplication.java b/src/main/java/com/somemore/SomemoreApplication.java index dec2413b5..db1465bb8 100644 --- a/src/main/java/com/somemore/SomemoreApplication.java +++ b/src/main/java/com/somemore/SomemoreApplication.java @@ -3,9 +3,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableScheduling; -@SpringBootApplication +@EnableScheduling @EnableJpaAuditing +@SpringBootApplication public class SomemoreApplication { public static void main(String[] args) { diff --git a/src/main/java/com/somemore/recruitboard/domain/RecruitBoard.java b/src/main/java/com/somemore/recruitboard/domain/RecruitBoard.java index 90b8293d4..982ba39e9 100644 --- a/src/main/java/com/somemore/recruitboard/domain/RecruitBoard.java +++ b/src/main/java/com/somemore/recruitboard/domain/RecruitBoard.java @@ -1,5 +1,6 @@ package com.somemore.recruitboard.domain; +import static com.somemore.recruitboard.domain.RecruitStatus.CLOSED; import static com.somemore.recruitboard.domain.RecruitStatus.COMPLETED; import static com.somemore.recruitboard.domain.RecruitStatus.RECRUITING; import static jakarta.persistence.EnumType.STRING; @@ -93,6 +94,14 @@ public void changeRecruitStatus(RecruitStatus newStatus, LocalDateTime currentDa this.recruitStatus = newStatus; } + public void markAsClosed() { + this.recruitStatus = CLOSED; + } + + public void markAsCompleted() { + this.recruitStatus = COMPLETED; + } + public boolean isRecruitOpen() { return this.recruitStatus == RECRUITING; } diff --git a/src/main/java/com/somemore/recruitboard/repository/RecruitBoardRepository.java b/src/main/java/com/somemore/recruitboard/repository/RecruitBoardRepository.java index 1f25c824d..f6079977a 100644 --- a/src/main/java/com/somemore/recruitboard/repository/RecruitBoardRepository.java +++ b/src/main/java/com/somemore/recruitboard/repository/RecruitBoardRepository.java @@ -1,11 +1,13 @@ package com.somemore.recruitboard.repository; import com.somemore.recruitboard.domain.RecruitBoard; +import com.somemore.recruitboard.domain.RecruitStatus; import com.somemore.recruitboard.dto.condition.RecruitBoardNearByCondition; import com.somemore.recruitboard.dto.condition.RecruitBoardSearchCondition; import com.somemore.recruitboard.repository.mapper.RecruitBoardDetail; import com.somemore.recruitboard.repository.mapper.RecruitBoardWithCenter; import com.somemore.recruitboard.repository.mapper.RecruitBoardWithLocation; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -30,4 +32,8 @@ public interface RecruitBoardRepository { List findNotCompletedIdsByCenterId(UUID centerId); List findAllByIds(List ids); + + List findByStartDateTimeBetweenAndStatus(LocalDateTime from, LocalDateTime to, RecruitStatus status); + + List findByEndDateTimeBeforeAndStatus(LocalDateTime current, RecruitStatus status); } diff --git a/src/main/java/com/somemore/recruitboard/repository/RecruitBoardRepositoryImpl.java b/src/main/java/com/somemore/recruitboard/repository/RecruitBoardRepositoryImpl.java index a2d68ede6..7e42ab29c 100644 --- a/src/main/java/com/somemore/recruitboard/repository/RecruitBoardRepositoryImpl.java +++ b/src/main/java/com/somemore/recruitboard/repository/RecruitBoardRepositoryImpl.java @@ -21,6 +21,7 @@ import com.somemore.recruitboard.repository.mapper.RecruitBoardDetail; import com.somemore.recruitboard.repository.mapper.RecruitBoardWithCenter; import com.somemore.recruitboard.repository.mapper.RecruitBoardWithLocation; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -87,6 +88,32 @@ public List findAllByIds(List ids) { .fetch(); } + @Override + public List findByStartDateTimeBetweenAndStatus(LocalDateTime from, + LocalDateTime to, RecruitStatus status) { + BooleanExpression exp = startDateTimeBetween(from, to) + .and(statusEq(status)) + .and(isNotDeleted()); + + return queryFactory + .selectFrom(recruitBoard) + .where(exp) + .fetch(); + } + + @Override + public List findByEndDateTimeBeforeAndStatus(LocalDateTime current, + RecruitStatus status) { + BooleanExpression exp = endDateTimeBefore(current) + .and(statusEq(status)) + .and(isNotDeleted()); + + return queryFactory + .selectFrom(recruitBoard) + .where(exp) + .fetch(); + } + @Override public Optional findWithLocationById(Long id) { QRecruitBoard recruitBoard = QRecruitBoard.recruitBoard; @@ -254,6 +281,14 @@ private BooleanExpression locationBetween(RecruitBoardNearByCondition condition) .and(location.longitude.between(minLongitude, maxLongitude)); } + private static BooleanExpression startDateTimeBetween(LocalDateTime from, LocalDateTime to) { + return recruitBoard.recruitmentInfo.volunteerStartDateTime.between(from, to); + } + + private static BooleanExpression endDateTimeBefore(LocalDateTime current) { + return recruitBoard.recruitmentInfo.volunteerEndDateTime.before(current); + } + private OrderSpecifier[] toOrderSpecifiers(Sort sort) { QRecruitBoard recruitBoard = QRecruitBoard.recruitBoard; diff --git a/src/main/java/com/somemore/recruitboard/scheduler/RecruitBoardStatusUpdateScheduler.java b/src/main/java/com/somemore/recruitboard/scheduler/RecruitBoardStatusUpdateScheduler.java new file mode 100644 index 000000000..19f629152 --- /dev/null +++ b/src/main/java/com/somemore/recruitboard/scheduler/RecruitBoardStatusUpdateScheduler.java @@ -0,0 +1,53 @@ +package com.somemore.recruitboard.scheduler; + +import static com.somemore.recruitboard.domain.RecruitStatus.CLOSED; +import static com.somemore.recruitboard.domain.RecruitStatus.RECRUITING; + +import com.somemore.recruitboard.domain.RecruitBoard; +import com.somemore.recruitboard.repository.RecruitBoardRepository; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@RequiredArgsConstructor +@Transactional +@Component +public class RecruitBoardStatusUpdateScheduler { + + private final RecruitBoardRepository recruitBoardRepository; + + @Scheduled(cron = "${spring.schedules.cron.updateBoardsToClosed}") + public synchronized void transitionBoardsToClosed() { + log.info("봉사 시작일에 해당하는 모집글 상태를 CLOSED로 변경하는 작업 시작"); + LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); + LocalDateTime startOfNextDay = LocalDate.now().plusDays(1).atStartOfDay(); + + List boards = recruitBoardRepository.findByStartDateTimeBetweenAndStatus( + startOfDay, startOfNextDay, RECRUITING); + + boards.forEach(RecruitBoard::markAsClosed); + + recruitBoardRepository.saveAll(boards); + } + + @Scheduled(cron = "${spring.schedules.cron.updateBoardsToCompleted}") + public synchronized void transitionBoardsToCompleted() { + + log.info("봉사 종료일에 해당하는 모집글 상태를 COMPLETED로 변경하는 작업을 시작"); + LocalDateTime now = LocalDateTime.now(); + + List boards = recruitBoardRepository.findByEndDateTimeBeforeAndStatus( + now, CLOSED); + + boards.forEach(RecruitBoard::markAsCompleted); + + recruitBoardRepository.saveAll(boards); + } +} + diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b350d50cd..47824b40f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -68,6 +68,11 @@ spring: max-file-size: 8MB max-request-size: 8MB + schedules: + cron: + updateBoardsToClosed: "0 0 0 * * ?" # updateBoardsToClosed 스케줄링 cron 표현식 + updateBoardsToCompleted: "0 0 0 * * ?" # updateBoardsToCompleted 스케줄링 cron 표현식 + #swagger springdoc: diff --git a/src/test/java/com/somemore/common/fixture/RecruitBoardFixture.java b/src/test/java/com/somemore/common/fixture/RecruitBoardFixture.java index 49fff9c33..15fc0253c 100644 --- a/src/test/java/com/somemore/common/fixture/RecruitBoardFixture.java +++ b/src/test/java/com/somemore/common/fixture/RecruitBoardFixture.java @@ -327,6 +327,13 @@ public static RecruitBoard createCloseRecruitBoard() { return recruitBoard; } + public static RecruitBoard createRecruitBoard(LocalDateTime start, LocalDateTime end, + RecruitStatus status) { + RecruitBoard board = createRecruitBoard(start, end); + setRecruitStatus(board, status); + return board; + } + private static void setRecruitStatus(RecruitBoard recruitBoard, RecruitStatus status) { try { Field recruitStatusField = RecruitBoard.class.getDeclaredField("recruitStatus"); diff --git a/src/test/java/com/somemore/recruitboard/domain/RecruitBoardTest.java b/src/test/java/com/somemore/recruitboard/domain/RecruitBoardTest.java index 35f47a461..59d63463d 100644 --- a/src/test/java/com/somemore/recruitboard/domain/RecruitBoardTest.java +++ b/src/test/java/com/somemore/recruitboard/domain/RecruitBoardTest.java @@ -204,4 +204,30 @@ void isRecruitOpen() { assertThat(result).isTrue(); } + @DisplayName("모집글을 상태를 모집 완료로 변경할 수 있다.") + @Test + void markAsClosed() { + // given + RecruitBoard board = createRecruitBoard(); + + // when + board.markAsClosed(); + + // then + assertThat(board.getRecruitStatus()).isEqualTo(CLOSED); + } + + @DisplayName("모집글 상태를 종료로 변경할 수 있다.") + @Test + void markAsCompleted() { + /// given + RecruitBoard board = createRecruitBoard(); + + // when + board.markAsCompleted(); + + // then + assertThat(board.getRecruitStatus()).isEqualTo(COMPLETED); + } + } diff --git a/src/test/java/com/somemore/recruitboard/repository/RecruitBoardRepositoryImplTest.java b/src/test/java/com/somemore/recruitboard/repository/RecruitBoardRepositoryImplTest.java index 1b0268c59..895ff82aa 100644 --- a/src/test/java/com/somemore/recruitboard/repository/RecruitBoardRepositoryImplTest.java +++ b/src/test/java/com/somemore/recruitboard/repository/RecruitBoardRepositoryImplTest.java @@ -6,7 +6,9 @@ import static com.somemore.common.fixture.RecruitBoardFixture.createCompletedRecruitBoard; import static com.somemore.common.fixture.RecruitBoardFixture.createRecruitBoard; import static com.somemore.recruitboard.domain.RecruitStatus.CLOSED; +import static com.somemore.recruitboard.domain.RecruitStatus.RECRUITING; import static com.somemore.recruitboard.domain.VolunteerCategory.ADMINISTRATIVE_SUPPORT; +import static org.assertj.core.api.Assertions.as; import static org.assertj.core.api.Assertions.assertThat; import com.somemore.IntegrationTestSupport; @@ -22,6 +24,7 @@ import com.somemore.recruitboard.repository.mapper.RecruitBoardDetail; import com.somemore.recruitboard.repository.mapper.RecruitBoardWithCenter; import com.somemore.recruitboard.repository.mapper.RecruitBoardWithLocation; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -426,6 +429,49 @@ void findAllByIds() { assertThat(all).hasSize(3); } + @DisplayName("주어진 날짜 범위와 모집글 상태로 모집글을 조회할 수 있다.") + @Test + void findByStartDateTimeBetweenAndStatus() { + // given + LocalDateTime from = LocalDate.now().plusDays(2).atStartOfDay(); + LocalDateTime to = from.plusDays(1); + + LocalDateTime startDateTime = from.plusHours(12); + LocalDateTime endDateTime = startDateTime.plusHours(1); + + RecruitBoard board = createRecruitBoard(startDateTime, endDateTime); + recruitBoardRepository.save(board); + + // when + List result = recruitBoardRepository.findByStartDateTimeBetweenAndStatus( + from, to, RECRUITING); + + // then + assertThat(result).hasSize(1); + assertThat(result.getFirst().getRecruitStatus()).isEqualTo(RECRUITING); + + } + + @DisplayName("봉사 모집글 상태가 같고 봉사 종료 시간이 지난 모집글 리스트를 조회할 수 있다.") + @Test + void findByEndDateTimeBeforeAndStatus() { + // given + LocalDateTime current = LocalDate.now().atStartOfDay(); + LocalDateTime startDateTime = current.minusHours(12); + LocalDateTime endDateTime = startDateTime.plusHours(1); + + RecruitBoard board = createRecruitBoard(startDateTime, endDateTime, CLOSED); + recruitBoardRepository.save(board); + + // when + List result = recruitBoardRepository.findByEndDateTimeBeforeAndStatus( + current, CLOSED); + + // then + assertThat(result).hasSize(1); + assertThat(result.getFirst().getRecruitStatus()).isEqualTo(CLOSED); + } + private Pageable getPageable() { Sort sort = Sort.by(Sort.Order.desc("created_at")); return PageRequest.of(0, 5, sort); diff --git a/src/test/java/com/somemore/recruitboard/scheduler/RecruitBoardStatusUpdateSchedulerTest.java b/src/test/java/com/somemore/recruitboard/scheduler/RecruitBoardStatusUpdateSchedulerTest.java new file mode 100644 index 000000000..28d96c37d --- /dev/null +++ b/src/test/java/com/somemore/recruitboard/scheduler/RecruitBoardStatusUpdateSchedulerTest.java @@ -0,0 +1,86 @@ +package com.somemore.recruitboard.scheduler; + +import static com.somemore.common.fixture.RecruitBoardFixture.createRecruitBoard; +import static com.somemore.recruitboard.domain.RecruitStatus.CLOSED; +import static com.somemore.recruitboard.domain.RecruitStatus.COMPLETED; +import static com.somemore.recruitboard.domain.RecruitStatus.RECRUITING; +import static org.assertj.core.api.Assertions.assertThat; + +import com.somemore.IntegrationTestSupport; +import com.somemore.recruitboard.domain.RecruitBoard; +import com.somemore.recruitboard.domain.RecruitStatus; +import com.somemore.recruitboard.repository.RecruitBoardRepository; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.awaitility.Awaitility; +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; + +@Transactional +class RecruitBoardStatusUpdateSchedulerTest extends IntegrationTestSupport { + + @Autowired + private RecruitBoardRepository recruitBoardRepository; + + @DisplayName("스케쥴링으로 봉사 시작일 00시에 모집글 상태가 모집 완료로 변경 된다.") + @Test + void transitionBoardsToClosed() { + // given + LocalDateTime start = LocalDate.now().atStartOfDay().plusHours(1); + LocalDateTime end = start.plusHours(2); + + List recruitBoards = getRecruitBoards(start, end, RECRUITING); + recruitBoardRepository.saveAll(recruitBoards); + + // when + // then + Awaitility.await() + .atMost(3, TimeUnit.SECONDS) + .untilAsserted(() -> { + List updatedBoards = recruitBoardRepository.findAllByIds( + recruitBoards.stream().map(RecruitBoard::getId).toList()); + assertThat(updatedBoards) + .allMatch(board -> board.getRecruitStatus() == CLOSED); + }); + } + + @DisplayName("스케쥴링으로 봉사 종료일 24시에 모집글 상태가 종료로 변경 된다.") + @Test + void transitionBoardsToCompleted() { + // given + LocalDateTime start = LocalDate.now().atStartOfDay().minusHours(12); + LocalDateTime end = start.plusHours(2); + + List recruitBoards = getRecruitBoards(start, end, CLOSED); + recruitBoardRepository.saveAll(recruitBoards); + + // when + // then + Awaitility.await() + .atMost(3, TimeUnit.SECONDS) + .untilAsserted(() -> { + List updatedBoards = recruitBoardRepository.findAllByIds( + recruitBoards.stream().map(RecruitBoard::getId).toList()); + assertThat(updatedBoards) + .allMatch(board -> board.getRecruitStatus() == COMPLETED); + }); + } + + private List getRecruitBoards(LocalDateTime start, LocalDateTime end, + RecruitStatus status) { + + List boards = new ArrayList<>(); + + for (int i = 0; i < 10; i++) { + RecruitBoard board = createRecruitBoard(start, end, status); + boards.add(board); + } + + return boards; + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 537ef65eb..eeb7880f0 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -37,6 +37,11 @@ spring: locale: ko_KR locale-resolver: fixed + schedules: + cron: + updateBoardsToClosed: "0/10 * * * * ?" # updateBoardsToClosed 스케줄링 cron 표현식 + updateBoardsToCompleted: "0/10 * * * * ?" # updateBoardsToCompleted 스케줄링 cron 표현식 + jwt: secret: 63bf2c80266cd25072e53b3482e318c30d1cd18d8c98d0f5d278530a94fe28d9fbbec531e5ccb58c725c125738182357786b71f43a7172c5d0c94a17f0da44f2 # 테스트용 JWT 시크릿 키