Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/main/java/com/somemore/SomemoreApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -30,4 +32,8 @@ public interface RecruitBoardRepository {
List<Long> findNotCompletedIdsByCenterId(UUID centerId);

List<RecruitBoard> findAllByIds(List<Long> ids);

List<RecruitBoard> findByStartDateTimeBetweenAndStatus(LocalDateTime from, LocalDateTime to, RecruitStatus status);

List<RecruitBoard> findByEndDateTimeBeforeAndStatus(LocalDateTime current, RecruitStatus status);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -87,6 +88,32 @@ public List<RecruitBoard> findAllByIds(List<Long> ids) {
.fetch();
}

@Override
public List<RecruitBoard> 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<RecruitBoard> findByEndDateTimeBeforeAndStatus(LocalDateTime current,
RecruitStatus status) {
BooleanExpression exp = endDateTimeBefore(current)
.and(statusEq(status))
.and(isNotDeleted());

return queryFactory
.selectFrom(recruitBoard)
.where(exp)
.fetch();
}

@Override
public Optional<RecruitBoardWithLocation> findWithLocationById(Long id) {
QRecruitBoard recruitBoard = QRecruitBoard.recruitBoard;
Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -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}")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
@Scheduled(cron = "${spring.schedules.cron.updateBoardsToClosed}")
@Retryable(
value = Exception.class,
maxAttempts = 3,
backoff = @Backoff(delay = 100000) // 100초 후 재시도
)
@Scheduled(cron = "${spring.schedules.cron.updateBoardsToClosed}")

이런 재시도 로직도 가능할 것 같아요!

public synchronized void transitionBoardsToClosed() {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

synchronized 키워드는 왜 적으셨는지 궁금합니다!
테스트 코드에서 스케줄러 호출때문에 그런걸까요?

log.info("봉사 시작일에 해당하는 모집글 상태를 CLOSED로 변경하는 작업 시작");
LocalDateTime startOfDay = LocalDate.now().atStartOfDay();
LocalDateTime startOfNextDay = LocalDate.now().plusDays(1).atStartOfDay();

List<RecruitBoard> boards = recruitBoardRepository.findByStartDateTimeBetweenAndStatus(
startOfDay, startOfNextDay, RECRUITING);

boards.forEach(RecruitBoard::markAsClosed);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아니면 여기서 실패 로직이 나오면 데드 레터 큐 방식으로 레디스로 서버 이벤트를 발행하고 처리할 수 있을 것 같아요~~

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
boards.forEach(RecruitBoard::markAsClosed);
boards.forEach(board -> {
try {
board.markAsClosed();
} catch (Exception e) {
// event 생성
serverEventPublisher.publish(event);
log.warn("보드 업데이트 실패: {} (ID: {})", e.getMessage(), board.getId());
}
});


recruitBoardRepository.saveAll(boards);
}

@Scheduled(cron = "${spring.schedules.cron.updateBoardsToCompleted}")
public synchronized void transitionBoardsToCompleted() {

log.info("봉사 종료일에 해당하는 모집글 상태를 COMPLETED로 변경하는 작업을 시작");
LocalDateTime now = LocalDateTime.now();

List<RecruitBoard> boards = recruitBoardRepository.findByEndDateTimeBeforeAndStatus(
now, CLOSED);

boards.forEach(RecruitBoard::markAsCompleted);

recruitBoardRepository.saveAll(boards);
}
}

5 changes: 5 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 표현식

Comment on lines +71 to +75
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

오 이것도 뺄수가 있군요
신기하네요


#swagger
springdoc:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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<RecruitBoard> 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<RecruitBoard> 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);
Expand Down
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

00시와 24시를 나눠주는 친절한 범수 님!
이해가 잘 됐습니다.

Original file line number Diff line number Diff line change
@@ -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<RecruitBoard> recruitBoards = getRecruitBoards(start, end, RECRUITING);
recruitBoardRepository.saveAll(recruitBoards);

// when
// then
Awaitility.await()
.atMost(3, TimeUnit.SECONDS)
.untilAsserted(() -> {
List<RecruitBoard> 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<RecruitBoard> recruitBoards = getRecruitBoards(start, end, CLOSED);
recruitBoardRepository.saveAll(recruitBoards);

// when
// then
Awaitility.await()
.atMost(3, TimeUnit.SECONDS)
.untilAsserted(() -> {
List<RecruitBoard> updatedBoards = recruitBoardRepository.findAllByIds(
recruitBoards.stream().map(RecruitBoard::getId).toList());
assertThat(updatedBoards)
.allMatch(board -> board.getRecruitStatus() == COMPLETED);
});
}

private List<RecruitBoard> getRecruitBoards(LocalDateTime start, LocalDateTime end,
RecruitStatus status) {

List<RecruitBoard> boards = new ArrayList<>();

for (int i = 0; i < 10; i++) {
RecruitBoard board = createRecruitBoard(start, end, status);
boards.add(board);
}

return boards;
}
}
5 changes: 5 additions & 0 deletions src/test/resources/application-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 시크릿 키

Expand Down
Loading