diff --git a/backend/src/main/java/moadong/MoadongApplication.java b/backend/src/main/java/moadong/MoadongApplication.java index 2174768f0..21e4067a2 100644 --- a/backend/src/main/java/moadong/MoadongApplication.java +++ b/backend/src/main/java/moadong/MoadongApplication.java @@ -4,9 +4,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication(exclude = {DataSourceAutoConfiguration.class}) @RequiredArgsConstructor +@EnableScheduling public class MoadongApplication { public static void main(String[] args) { diff --git a/backend/src/main/java/moadong/club/entity/Club.java b/backend/src/main/java/moadong/club/entity/Club.java index 721fe8ba5..879637d70 100644 --- a/backend/src/main/java/moadong/club/entity/Club.java +++ b/backend/src/main/java/moadong/club/entity/Club.java @@ -10,6 +10,7 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import moadong.club.enums.ClubRecruitmentStatus; import moadong.club.enums.ClubState; import moadong.club.payload.request.ClubInfoRequest; import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest; @@ -93,4 +94,8 @@ public void updateLogo(String logo) { public void updateFeedImages(List feedImages) { this.clubRecruitmentInformation.updateFeedImages(feedImages); } + + public void updateRecruitmentStatus(ClubRecruitmentStatus clubRecruitmentStatus) { + this.clubRecruitmentInformation.updateRecruitmentStatus(clubRecruitmentStatus); + } } diff --git a/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java b/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java index e891e0391..6c94b727f 100644 --- a/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java +++ b/backend/src/main/java/moadong/club/entity/ClubRecruitmentInformation.java @@ -80,11 +80,17 @@ public boolean hasRecruitmentPeriod() { public ZonedDateTime getRecruitmentStart() { ZoneId seoulZone = ZoneId.of("Asia/Seoul"); + if (recruitmentStart == null) { + return null; + } return recruitmentStart.atZone(seoulZone); } public ZonedDateTime getRecruitmentEnd() { ZoneId seoulZone = ZoneId.of("Asia/Seoul"); + if (recruitmentEnd == null) { + return null; + } return recruitmentEnd.atZone(seoulZone); } diff --git a/backend/src/main/java/moadong/club/service/ClubProfileService.java b/backend/src/main/java/moadong/club/service/ClubProfileService.java index 6640a1fa2..c3b5e88d4 100644 --- a/backend/src/main/java/moadong/club/service/ClubProfileService.java +++ b/backend/src/main/java/moadong/club/service/ClubProfileService.java @@ -4,8 +4,8 @@ import moadong.club.entity.Club; import moadong.club.payload.dto.ClubDetailedResult; import moadong.club.payload.request.ClubCreateRequest; -import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest; import moadong.club.payload.request.ClubInfoRequest; +import moadong.club.payload.request.ClubRecruitmentInfoUpdateRequest; import moadong.club.payload.response.ClubDetailedResponse; import moadong.club.repository.ClubRepository; import moadong.global.exception.ErrorCode; @@ -20,14 +20,13 @@ public class ClubProfileService { private final ClubRepository clubRepository; - private final RecruitmentScheduler recruitmentScheduler; public String createClub(ClubCreateRequest request) { Club club = Club.builder() - .name(request.name()) - .category(request.category()) - .division(request.division()) - .build(); + .name(request.name()) + .category(request.category()) + .division(request.division()) + .build(); clubRepository.save(club); return club.getId(); @@ -40,34 +39,28 @@ public void updateClubInfo(ClubInfoRequest request, CustomUserDetails user) { clubRepository.save(club); } - public void updateClubRecruitmentInfo(ClubRecruitmentInfoUpdateRequest request, CustomUserDetails user) { + public void updateClubRecruitmentInfo(ClubRecruitmentInfoUpdateRequest request, + CustomUserDetails user) { Club club = validateClubUpdateRequest(request.id(), user); - club.update(request); clubRepository.save(club); - - //모집일정을 동적스케쥴러에 달아둠 - if (request.recruitmentStart() != null && request.recruitmentEnd() != null) { - recruitmentScheduler.scheduleRecruitment(club.getId(), request.recruitmentStart(), - request.recruitmentEnd()); - } } public ClubDetailedResponse getClubDetail(String clubId) { ObjectId objectId = ObjectIdConverter.convertString(clubId); Club club = clubRepository.findClubById(objectId) - .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); + .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); ClubDetailedResult clubDetailedResult = ClubDetailedResult.of( - club + club ); return new ClubDetailedResponse(clubDetailedResult); } - private Club validateClubUpdateRequest(String clubId, CustomUserDetails user){ + private Club validateClubUpdateRequest(String clubId, CustomUserDetails user) { Club club = clubRepository.findById(clubId) - .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); - if (!user.getId().equals(club.getUserId())){ + .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); + if (!user.getId().equals(club.getUserId())) { throw new RestApiException(ErrorCode.USER_UNAUTHORIZED); } return club; diff --git a/backend/src/main/java/moadong/club/service/RecruitmentScheduler.java b/backend/src/main/java/moadong/club/service/RecruitmentScheduler.java deleted file mode 100644 index 56b81895a..000000000 --- a/backend/src/main/java/moadong/club/service/RecruitmentScheduler.java +++ /dev/null @@ -1,69 +0,0 @@ -package moadong.club.service; - -import jakarta.transaction.Transactional; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.Date; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ScheduledFuture; -import lombok.RequiredArgsConstructor; -import moadong.club.entity.Club; -import moadong.club.enums.ClubRecruitmentStatus; -import moadong.club.repository.ClubRepository; -import moadong.global.exception.ErrorCode; -import moadong.global.exception.RestApiException; -import moadong.global.util.ObjectIdConverter; -import org.bson.types.ObjectId; -import org.springframework.scheduling.TaskScheduler; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -public class RecruitmentScheduler { - - private final TaskScheduler taskScheduler; - - private final Map> scheduledTasks = new ConcurrentHashMap<>(); - - private final ClubRepository clubRepository; - - public void scheduleRecruitment(String clubId, LocalDateTime startDate, - LocalDateTime endDate) { - cancelScheduledTask(clubId); // 기존 스케줄 제거 후 등록 - - // 모집 시작 스케줄링 - ScheduledFuture startFuture = taskScheduler.schedule( - () -> updateRecruitmentStatus(clubId, ClubRecruitmentStatus.OPEN), - Date.from(startDate.atZone(ZoneId.of("Asia/Seoul")).toInstant())); - - // 모집 종료 스케줄링 - ScheduledFuture endFuture = taskScheduler.schedule( - () -> updateRecruitmentStatus(clubId, ClubRecruitmentStatus.CLOSED), - Date.from(endDate.atZone(ZoneId.of("Asia/Seoul")).toInstant())); - - scheduledTasks.put(clubId, startFuture); - scheduledTasks.put(clubId, endFuture); - } - - public void cancelScheduledTask(String clubId) { - ScheduledFuture future = scheduledTasks.remove(clubId); - if (future != null) { - future.cancel(false); - } - } - - @Transactional - public void updateRecruitmentStatus(String clubId, ClubRecruitmentStatus status) { - ObjectId objectId = ObjectIdConverter.convertString(clubId); - Club club = clubRepository.findClubById(objectId) - .orElseThrow(() -> new RestApiException(ErrorCode.CLUB_NOT_FOUND)); - - club.getClubRecruitmentInformation().updateRecruitmentStatus(status); - clubRepository.save(club); - } - - public Map> getScheduledTasks() { - return scheduledTasks; - } -} diff --git a/backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java b/backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java new file mode 100644 index 000000000..796cb6100 --- /dev/null +++ b/backend/src/main/java/moadong/club/service/RecruitmentStateChecker.java @@ -0,0 +1,51 @@ +package moadong.club.service; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import lombok.RequiredArgsConstructor; +import moadong.club.entity.Club; +import moadong.club.entity.ClubRecruitmentInformation; +import moadong.club.enums.ClubRecruitmentStatus; +import moadong.club.repository.ClubRepository; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RecruitmentStateChecker { + + private final ClubRepository clubRepository; + + @Scheduled(fixedRate = 60 * 60 * 1000) // 5분마다 실행 + public void performTask() { + List clubs = clubRepository.findAll(); + for (Club club : clubs) { + ClubRecruitmentInformation recruitInfo = club.getClubRecruitmentInformation(); + ZonedDateTime recruitmentStartDate = recruitInfo.getRecruitmentStart(); + ZonedDateTime recruitmentEndDate = recruitInfo.getRecruitmentEnd(); + if (recruitInfo.getClubRecruitmentStatus() == ClubRecruitmentStatus.ALWAYS) { + continue; + } + if (recruitmentStartDate != null && recruitmentEndDate != null) { + ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); + if (now.isBefore(recruitmentStartDate)) { + long between = ChronoUnit.DAYS.between(recruitmentStartDate, now); + if (between <= 14) { + club.updateRecruitmentStatus(ClubRecruitmentStatus.UPCOMING); + } else { + club.updateRecruitmentStatus(ClubRecruitmentStatus.CLOSED); + } + } else if (now.isAfter(recruitmentStartDate) && now.isBefore(recruitmentEndDate)) { + club.updateRecruitmentStatus(ClubRecruitmentStatus.OPEN); + } else if (now.isAfter(recruitmentEndDate)) { + club.updateRecruitmentStatus(ClubRecruitmentStatus.CLOSED); + } + } else { + club.updateRecruitmentStatus(ClubRecruitmentStatus.CLOSED); + } + clubRepository.save(club); + } + } +} diff --git a/backend/src/main/java/moadong/global/config/SchedulerConfig.java b/backend/src/main/java/moadong/global/config/SchedulerConfig.java deleted file mode 100644 index ffcc46808..000000000 --- a/backend/src/main/java/moadong/global/config/SchedulerConfig.java +++ /dev/null @@ -1,19 +0,0 @@ -package moadong.global.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.scheduling.TaskScheduler; -import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; - -@Configuration -public class SchedulerConfig { - - @Bean - public TaskScheduler taskScheduler() { - ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); - scheduler.setPoolSize(5); // 동시에 실행할 스레드 개수 설정 - scheduler.setThreadNamePrefix("RecruitmentScheduler-"); - scheduler.initialize(); - return scheduler; - } -} diff --git a/backend/src/test/java/moadong/club/service/RecruitmentSchedulerTest.java b/backend/src/test/java/moadong/club/service/RecruitmentSchedulerTest.java deleted file mode 100644 index 3282915fd..000000000 --- a/backend/src/test/java/moadong/club/service/RecruitmentSchedulerTest.java +++ /dev/null @@ -1,161 +0,0 @@ -package moadong.club.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.time.temporal.ChronoUnit; -import java.util.Date; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.concurrent.ScheduledFuture; -import moadong.club.entity.Club; -import moadong.club.enums.ClubRecruitmentStatus; -import moadong.club.repository.ClubRepository; -import moadong.global.exception.ErrorCode; -import moadong.global.exception.RestApiException; -import org.bson.types.ObjectId; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Captor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.scheduling.TaskScheduler; - -@ExtendWith(MockitoExtension.class) -public class RecruitmentSchedulerTest { - - @InjectMocks - private RecruitmentScheduler recruitmentScheduler; - - @Mock - private ClubRepository clubRepository; - - @Mock - private ScheduledFuture scheduledFuture; - - @Mock - private TaskScheduler taskScheduler; - - @Captor - private ArgumentCaptor runnableCaptor; - - @Captor - private ArgumentCaptor dateCaptor; - - private Map> scheduledTasks; - - @BeforeEach - void setUp() { - scheduledTasks = recruitmentScheduler.getScheduledTasks(); - } - - @Nested - class scheduleRecruitment { - - @Test - void 모집_스케줄링_성공() { - // given - String clubId = "club-1"; - - ZoneId koreaZoneId = ZoneId.of("Asia/Seoul"); - LocalDateTime now = ZonedDateTime.now(koreaZoneId).toLocalDateTime(); - LocalDateTime startDate = now.plusDays(1); - Instant expectedStartInstant = startDate.atZone(koreaZoneId).toInstant() - .truncatedTo(ChronoUnit.MILLIS); - LocalDateTime endDate = now.plusDays(2); - Instant expectedEndInstant = endDate.atZone(koreaZoneId).toInstant() - .truncatedTo(ChronoUnit.MILLIS); - - when(taskScheduler.schedule(any(Runnable.class), any(Date.class))) - .thenReturn((ScheduledFuture) scheduledFuture); - - // when - recruitmentScheduler.scheduleRecruitment(clubId, startDate, endDate); - - //then - verify(taskScheduler, times(2)).schedule(runnableCaptor.capture(), - dateCaptor.capture()); - - // 모집 시장 스케줄링 검증 - List dates = dateCaptor.getAllValues(); - Date startScheduleDate = dates.get(0); - assertEquals(expectedStartInstant, - startScheduleDate.toInstant()); - - // 모집 종료 스케줄링 검증 - Date endScheduleDate = dates.get(1); - assertEquals(expectedEndInstant, - endScheduleDate.toInstant()); - } - } - - @Nested - class cancelScheduledTask { - - @Test - void 기존_스케줄_취소_성공() { - // given - String clubId = "club123"; - scheduledTasks.put(clubId, scheduledFuture); - - // when - recruitmentScheduler.cancelScheduledTask(clubId); - - // then - verify(scheduledFuture).cancel(false); - assert (scheduledTasks.get(clubId) == null); // 태스크가 맵에서 제거되었는지 확인 - } - } - - @Nested - class updateRecruitmentStatus { - - @Test - void 모집상태_업데이트_성공() { - // given - String clubId = new ObjectId().toHexString(); - ClubRecruitmentStatus status = ClubRecruitmentStatus.OPEN; - - Club club = new Club(); - - when(clubRepository.findClubById(any(ObjectId.class))).thenReturn(Optional.of(club)); - - // when - recruitmentScheduler.updateRecruitmentStatus(clubId, status); - - // then - verify(clubRepository).findClubById(any(ObjectId.class)); - verify(clubRepository).save(club); - } - - @Test - void 모집상태_업데이트_실패_존재하지않는_클럽() { - // given - String clubId = new ObjectId().toHexString(); - ClubRecruitmentStatus status = ClubRecruitmentStatus.OPEN; - - when(clubRepository.findClubById(any(ObjectId.class))).thenReturn(Optional.empty()); - - // when, then - try { - recruitmentScheduler.updateRecruitmentStatus(clubId, status); - } catch (Exception e) { - assert (e instanceof RestApiException); - assert (((RestApiException) e).getErrorCode().equals( - ErrorCode.CLUB_NOT_FOUND)); - } - } - } -} diff --git a/backend/src/test/java/moadong/club/service/RecruitmentStateCheckerTest.java b/backend/src/test/java/moadong/club/service/RecruitmentStateCheckerTest.java new file mode 100644 index 000000000..fdb54f69f --- /dev/null +++ b/backend/src/test/java/moadong/club/service/RecruitmentStateCheckerTest.java @@ -0,0 +1,125 @@ +package moadong.club.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; +import moadong.club.entity.Club; +import moadong.club.entity.ClubRecruitmentInformation; +import moadong.club.enums.ClubRecruitmentStatus; +import moadong.club.repository.ClubRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class RecruitmentStateCheckerTest { + + @InjectMocks + private RecruitmentStateChecker recruitmentStateChecker; + + @Mock + private ClubRepository clubRepository; + + static final ZonedDateTime NOW = ZonedDateTime.now(ZoneId.of("Asia/Seoul")); + + @Test + void 모집상태_ALWAYS_이면_변경되지_않음() { + Club club = mock(Club.class); + ClubRecruitmentInformation info = mock(ClubRecruitmentInformation.class); + + when(club.getClubRecruitmentInformation()).thenReturn(info); + when(info.getClubRecruitmentStatus()).thenReturn(ClubRecruitmentStatus.ALWAYS); + + when(clubRepository.findAll()).thenReturn(List.of(club)); + + recruitmentStateChecker.performTask(); + + verify(club, never()).updateRecruitmentStatus(any()); + verify(clubRepository, never()).save(club); + } + + @Test + void 모집시작전_14일이내면_UPCOMING() { + Club club = mock(Club.class); + ClubRecruitmentInformation info = mock(ClubRecruitmentInformation.class); + + ZonedDateTime start = NOW.plusDays(10); + ZonedDateTime end = NOW.plusDays(20); + + when(club.getClubRecruitmentInformation()).thenReturn(info); + when(info.getClubRecruitmentStatus()).thenReturn(ClubRecruitmentStatus.CLOSED); + when(info.getRecruitmentStart()).thenReturn(start); + when(info.getRecruitmentEnd()).thenReturn(end); + when(clubRepository.findAll()).thenReturn(List.of(club)); + + recruitmentStateChecker.performTask(); + + verify(club).updateRecruitmentStatus(ClubRecruitmentStatus.UPCOMING); + verify(clubRepository).save(club); + } + + @Test + void 모집기간중이면_OPEN() { + Club club = mock(Club.class); + ClubRecruitmentInformation info = mock(ClubRecruitmentInformation.class); + + ZonedDateTime start = NOW.minusDays(1); + ZonedDateTime end = NOW.plusDays(5); + + when(club.getClubRecruitmentInformation()).thenReturn(info); + when(info.getClubRecruitmentStatus()).thenReturn(ClubRecruitmentStatus.CLOSED); + when(info.getRecruitmentStart()).thenReturn(start); + when(info.getRecruitmentEnd()).thenReturn(end); + when(clubRepository.findAll()).thenReturn(List.of(club)); + + recruitmentStateChecker.performTask(); + + verify(club).updateRecruitmentStatus(ClubRecruitmentStatus.OPEN); + verify(clubRepository).save(club); + } + + @Test + void 모집마감_이후면_CLOSED() { + Club club = mock(Club.class); + ClubRecruitmentInformation info = mock(ClubRecruitmentInformation.class); + + ZonedDateTime start = NOW.minusDays(10); + ZonedDateTime end = NOW.minusDays(1); + + when(club.getClubRecruitmentInformation()).thenReturn(info); + when(info.getClubRecruitmentStatus()).thenReturn(ClubRecruitmentStatus.OPEN); + when(info.getRecruitmentStart()).thenReturn(start); + when(info.getRecruitmentEnd()).thenReturn(end); + when(clubRepository.findAll()).thenReturn(List.of(club)); + + recruitmentStateChecker.performTask(); + + verify(club).updateRecruitmentStatus(ClubRecruitmentStatus.CLOSED); + verify(clubRepository).save(club); + } + + @Test + void 시작_또는_종료날짜_null이면_CLOSED() { + Club club = mock(Club.class); + ClubRecruitmentInformation info = mock(ClubRecruitmentInformation.class); + + when(club.getClubRecruitmentInformation()).thenReturn(info); + when(info.getClubRecruitmentStatus()).thenReturn(ClubRecruitmentStatus.OPEN); + when(info.getRecruitmentStart()).thenReturn(null); + when(info.getRecruitmentEnd()).thenReturn(null); + when(clubRepository.findAll()).thenReturn(List.of(club)); + + recruitmentStateChecker.performTask(); + + verify(club).updateRecruitmentStatus(ClubRecruitmentStatus.CLOSED); + verify(clubRepository).save(club); + } +}