Skip to content

Commit

Permalink
fix: 봉사 모집글 만료 스케줄링 간격을 수정한다. (#420)
Browse files Browse the repository at this point in the history
* fix: 봉사 모집글 마감 스케줄러 표현식을 수정한다.

* refactor: 마감 시간이 지난 봉사 모집글 마감 쿼리를 수정한다.

* fix: 봉사 모집글 목록 조회 시 응답 dto에서 마감을 판단하는 로직을 제거한다.

* teat: 봉사 모집글 마감 캐시 로직 테스트를 추가한다.
  • Loading branch information
hseong3243 authored Dec 3, 2023
1 parent e321305 commit 2d97f55
Show file tree
Hide file tree
Showing 11 changed files with 199 additions and 41 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public static FindRecruitmentDetailResponse from(Recruitment recruitment) {
recruitment.getContent(),
recruitment.getStartTime(),
recruitment.getEndTime(),
recruitment.isClosed() || recruitment.getDeadline().isBefore(LocalDateTime.now()),
recruitment.isClosed(),
recruitment.getDeadline(),
recruitment.getCreatedAt(),
recruitment.getUpdatedAt(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ private static RecruitmentResponse from(Recruitment recruitment){
recruitment.getStartTime(),
recruitment.getEndTime(),
recruitment.getDeadline(),
recruitment.isClosed() || recruitment.getDeadline().isBefore(LocalDateTime.now()),
recruitment.isClosed(),
recruitment.getApplicantCount(),
recruitment.getCapacity()
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ public record FindRecruitmentResponse(
int recruitmentApplicantCount,
int recruitmentCapacity,
String shelterName,
String shelterImageUrl) {
String shelterImageUrl,
LocalDateTime recruitmentCreatedAt) {

public static FindRecruitmentResponse from(Recruitment recruitment) {
return new FindRecruitmentResponse(
Expand All @@ -36,11 +37,12 @@ public static FindRecruitmentResponse from(Recruitment recruitment) {
recruitment.getStartTime(),
recruitment.getEndTime(),
recruitment.getDeadline(),
recruitment.isClosed() || recruitment.getDeadline().isBefore(LocalDateTime.now()),
recruitment.isClosed(),
recruitment.getApplicantCount(),
recruitment.getCapacity(),
recruitment.getShelter().getName(),
recruitment.getShelter().getImage()
recruitment.getShelter().getImage(),
recruitment.getCreatedAt()
);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

import com.clova.anifriends.domain.recruitment.Recruitment;
import com.clova.anifriends.domain.recruitment.dto.response.FindRecruitmentsResponse.FindRecruitmentResponse;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
Expand All @@ -22,6 +25,7 @@ public class RecruitmentCacheRepository {
private static final int MAX_CACHED_SIZE = 30;
private static final int PAGE_SIZE = 20;
private static final int ZERO = 0;
public static final ZoneOffset CREATED_AT_SCORE_TIME_ZONE = ZoneOffset.UTC;

private final ZSetOperations<String, FindRecruitmentResponse> cachedRecruitments;

Expand Down Expand Up @@ -88,7 +92,7 @@ public void update(final Recruitment recruitment) {
}

private long getCreatedAtScore(Recruitment recruitment) {
return recruitment.getCreatedAt().toEpochSecond(ZoneOffset.UTC);
return recruitment.getCreatedAt().toEpochSecond(CREATED_AT_SCORE_TIME_ZONE);
}

private boolean isEqualsId(
Expand All @@ -101,4 +105,51 @@ public void delete(final Recruitment recruitment) {
FindRecruitmentResponse recruitmentResponse = FindRecruitmentResponse.from(recruitment);
cachedRecruitments.remove(RECRUITMENT_KEY, recruitmentResponse);
}

public void closeRecruitmentsIfNeedToBe() {
LocalDateTime now = LocalDateTime.now();
Set<FindRecruitmentResponse> findRecruitments = cachedRecruitments.range(RECRUITMENT_KEY,
ZERO, UNTIL_LAST_ELEMENT);
if(Objects.nonNull(findRecruitments)) {
Map<FindRecruitmentResponse, FindRecruitmentResponse> cachedKeyAndUpdatedValue
= new HashMap<>();
findRecruitments.stream()
.filter(recruitment -> needToClose(recruitment, now))
.forEach(recruitment -> {
FindRecruitmentResponse closedRecruitment = closeCachedRecruitment(recruitment);
cachedKeyAndUpdatedValue.put(recruitment, closedRecruitment);
});

cachedKeyAndUpdatedValue.forEach((key, value) -> {
cachedRecruitments.remove(RECRUITMENT_KEY, key);
long createdAtScore = value.recruitmentCreatedAt()
.toEpochSecond(CREATED_AT_SCORE_TIME_ZONE);
cachedRecruitments.add(RECRUITMENT_KEY, value, createdAtScore);
});
}
}

private boolean needToClose(FindRecruitmentResponse recruitment, LocalDateTime now) {
boolean isClosed = recruitment.recruitmentIsClosed();
LocalDateTime deadline = recruitment.recruitmentDeadline();
boolean notYetClosed = !isClosed;
boolean passedDeadline = deadline.isBefore(now) || deadline.isEqual(now);
return notYetClosed && passedDeadline;
}

private FindRecruitmentResponse closeCachedRecruitment(FindRecruitmentResponse recruitment) {
return new FindRecruitmentResponse(
recruitment.recruitmentId(),
recruitment.recruitmentTitle(),
recruitment.recruitmentStartTime(),
recruitment.recruitmentEndTime(),
recruitment.recruitmentDeadline(),
true,
recruitment.recruitmentApplicantCount(),
recruitment.recruitmentCapacity(),
recruitment.shelterName(),
recruitment.shelterImageUrl(),
recruitment.recruitmentCreatedAt()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

Expand Down Expand Up @@ -54,5 +55,9 @@ List<Recruitment> findRecruitmentsByStartTime(@Param("time1") LocalDateTime time
List<Recruitment> findRecruitmentsByEndTime(@Param("time1") LocalDateTime time1,
@Param("time2") LocalDateTime time2);

List<Recruitment> findByInfo_IsClosedFalseAndInfo_DeadlineBefore(LocalDateTime deadline);
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("update Recruitment r set r.info.isClosed = true"
+ " where r.info.isClosed = false"
+ " and r.info.deadline <= now()")
void closeRecruitmentsIfNeedToBe();
}
Original file line number Diff line number Diff line change
Expand Up @@ -283,10 +283,7 @@ private Recruitment getRecruitmentByShelter(long shelterId,

@Transactional
public void autoCloseRecruitment() {
LocalDateTime now = LocalDateTime.now();
List<Recruitment> recruitments = recruitmentRepository
.findByInfo_IsClosedFalseAndInfo_DeadlineBefore(now);
recruitments.forEach(Recruitment::closeRecruitment);
recruitments.forEach(recruitmentCacheRepository::update);
recruitmentRepository.closeRecruitmentsIfNeedToBe();
recruitmentCacheRepository.closeRecruitmentsIfNeedToBe();
}
}
2 changes: 1 addition & 1 deletion src/main/resources/backend-config
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ void findRecruitments() throws Exception {
Shelter shelter = shelter();
Recruitment recruitment = recruitment(shelter);
ReflectionTestUtils.setField(recruitment, "recruitmentId", 1L);
ReflectionTestUtils.setField(recruitment, "createdAt", LocalDateTime.now());
FindRecruitmentResponse findRecruitmentResponse
= FindRecruitmentResponse.from(recruitment);
PageInfo pageInfo = new PageInfo(1, false);
Expand Down Expand Up @@ -245,6 +246,8 @@ void findRecruitments() throws Exception {
fieldWithPath("recruitments[].shelterName").type(STRING).description("보호소 이름"),
fieldWithPath("recruitments[].shelterImageUrl").type(STRING)
.description("보호소 이미지 url").optional(),
fieldWithPath("recruitments[].recruitmentCreatedAt").type(STRING)
.description("봉사 모집글 생성 시간"),
fieldWithPath("pageInfo").type(OBJECT).description("페이지 정보"),
fieldWithPath("pageInfo.totalElements").type(NUMBER).description("총 요소 개수"),
fieldWithPath("pageInfo.hasNext").type(BOOLEAN).description("다음 페이지 여부")
Expand All @@ -269,6 +272,7 @@ void findRecruitmentsV2() throws Exception {
Shelter shelter = shelter();
Recruitment recruitment = recruitment(shelter);
ReflectionTestUtils.setField(recruitment, "recruitmentId", 1L);
ReflectionTestUtils.setField(recruitment, "createdAt", LocalDateTime.now());
FindRecruitmentResponse findRecruitmentResponse
= FindRecruitmentResponse.from(recruitment);
PageInfo pageInfo = new PageInfo(1, false);
Expand Down Expand Up @@ -326,6 +330,8 @@ void findRecruitmentsV2() throws Exception {
fieldWithPath("recruitments[].shelterName").type(STRING).description("보호소 이름"),
fieldWithPath("recruitments[].shelterImageUrl").type(STRING)
.description("보호소 이미지 url").optional(),
fieldWithPath("recruitments[].recruitmentCreatedAt").type(STRING)
.description("봉사 모집글 생성 시간"),
fieldWithPath("pageInfo").type(OBJECT).description("페이지 정보"),
fieldWithPath("pageInfo.totalElements").type(NUMBER).description("총 요소 개수"),
fieldWithPath("pageInfo.hasNext").type(BOOLEAN).description("다음 페이지 여부")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,15 @@
import com.clova.anifriends.domain.recruitment.Recruitment;
import com.clova.anifriends.domain.recruitment.dto.response.FindRecruitmentsResponse.FindRecruitmentResponse;
import com.clova.anifriends.domain.recruitment.support.fixture.RecruitmentFixture;
import com.clova.anifriends.domain.recruitment.vo.RecruitmentInfo;
import com.clova.anifriends.domain.shelter.Shelter;
import com.clova.anifriends.domain.shelter.support.ShelterFixture;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.stream.IntStream;
import org.junit.jupiter.api.AfterEach;
Expand Down Expand Up @@ -135,7 +137,8 @@ void findAllWhenEmpty() {
PageRequest pageRequest = PageRequest.of(0, 20);

//when
Slice<FindRecruitmentResponse> recruitments = recruitmentCacheRepository.findAll(pageRequest);
Slice<FindRecruitmentResponse> recruitments = recruitmentCacheRepository.findAll(
pageRequest);

//then
assertThat(recruitments.getContent())
Expand Down Expand Up @@ -350,4 +353,77 @@ void ignoreWhenCachedRecruitmentDoesNotExists() {
assertThat(recruitments).isEmpty();
}
}

@Nested
@DisplayName("closeRecruitmentsIfNeedToBe 메서드 호출 시")
class CloseRecruitmentsIfNeedToBeTest {

Shelter shelter;

@BeforeEach
void setUp() {
shelter = ShelterFixture.shelter();
shelterRepository.save(shelter);
}

@Test
@DisplayName("성공: A(마감 대상), B(마감 시간 전)")
void closeRecruitmentsIfNeedToBe() {
//given
Recruitment recruitmentB = RecruitmentFixture.recruitment(shelter);
Recruitment recruitmentA = RecruitmentFixture.recruitment(shelter);
RecruitmentInfo recruitmentInfo = new RecruitmentInfo(recruitmentA.getStartTime(),
recruitmentA.getEndTime(), recruitmentA.getDeadline(), recruitmentA.isClosed(),
recruitmentA.getCapacity());
LocalDateTime deadlineBeforeNow = LocalDateTime.now().minusDays(1);
ReflectionTestUtils.setField(recruitmentInfo, "deadline", deadlineBeforeNow);
ReflectionTestUtils.setField(recruitmentA, "info", recruitmentInfo);
recruitmentRepository.save(recruitmentA);
recruitmentRepository.save(recruitmentB);
recruitmentCacheRepository.save(recruitmentA);
recruitmentCacheRepository.save(recruitmentB);

//when
recruitmentCacheRepository.closeRecruitmentsIfNeedToBe();

//then
Set<FindRecruitmentResponse> cachedRecruitments = redisTemplate.opsForZSet()
.range(RECRUITMENT_KEY, ZERO, ALL_ELEMENT);
Optional<FindRecruitmentResponse> findRecruitmentA = cachedRecruitments.stream()
.filter(FindRecruitmentResponse::recruitmentIsClosed)
.findFirst();
Optional<FindRecruitmentResponse> findRecruitmentB = cachedRecruitments.stream()
.filter(recruitment -> !recruitment.recruitmentIsClosed())
.findFirst();
assertThat(findRecruitmentA).isNotEmpty();
assertThat(findRecruitmentB).isNotEmpty();
assertThat(findRecruitmentA.get().recruitmentIsClosed()).isTrue();
assertThat(findRecruitmentB.get().recruitmentIsClosed()).isFalse();
}

@Test
@DisplayName("성공: A(이미 마감 됨)")
void closeRecruitmentsIfNeedToBeButAlreadyClosed() {
//given
Recruitment recruitmentA = RecruitmentFixture.recruitment(shelter);
RecruitmentInfo recruitmentInfo = new RecruitmentInfo(recruitmentA.getStartTime(),
recruitmentA.getEndTime(), recruitmentA.getDeadline(), recruitmentA.isClosed(),
recruitmentA.getCapacity());
ReflectionTestUtils.setField(recruitmentInfo, "isClosed", true);
ReflectionTestUtils.setField(recruitmentA, "info", recruitmentInfo);
recruitmentRepository.save(recruitmentA);
recruitmentCacheRepository.save(recruitmentA);

//when
recruitmentCacheRepository.closeRecruitmentsIfNeedToBe();

//then
Set<FindRecruitmentResponse> cachedRecruitments = redisTemplate.opsForZSet()
.range(RECRUITMENT_KEY, ZERO, ALL_ELEMENT);
Optional<FindRecruitmentResponse> findRecruitmentA = cachedRecruitments.stream()
.filter(FindRecruitmentResponse::recruitmentIsClosed)
.findFirst();
assertThat(findRecruitmentA).isNotEmpty();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,20 @@
import com.clova.anifriends.domain.recruitment.RecruitmentImage;
import com.clova.anifriends.domain.recruitment.repository.RecruitmentRepository;
import com.clova.anifriends.domain.recruitment.support.fixture.RecruitmentFixture;
import com.clova.anifriends.domain.recruitment.vo.RecruitmentInfo;
import com.clova.anifriends.domain.shelter.Shelter;
import com.clova.anifriends.domain.shelter.repository.ShelterRepository;
import com.clova.anifriends.domain.shelter.support.ShelterFixture;
import com.clova.anifriends.domain.volunteer.Volunteer;
import com.clova.anifriends.domain.volunteer.support.VolunteerFixture;
import java.time.LocalDateTime;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.util.ReflectionTestUtils;

public class RecruitmentIntegrationTest extends BaseIntegrationTest {

Expand Down Expand Up @@ -124,4 +127,39 @@ void deleteRecruitmentWhenExistsApplicants() {
assertThat(findRecruitment).isNull();
}
}

@Nested
@DisplayName("autoCloseRecruitment 메서드 호출 시")
class AutoCloseRecruitmentTest {

Shelter shelter;

@BeforeEach
void setUp() {
shelter = ShelterFixture.shelter();
shelterRepository.save(shelter);
}

@Test
@DisplayName("성공: 저장소 업데이트 됨")
void autoCloseRecruitment() {
//given
Recruitment recruitment = RecruitmentFixture.recruitment(shelter);
RecruitmentInfo recruitmentInfo = new RecruitmentInfo(recruitment.getStartTime(),
recruitment.getEndTime(), recruitment.getDeadline(), recruitment.isClosed(),
recruitment.getCapacity());
LocalDateTime deadlineBeforeNow = LocalDateTime.now().minusDays(1);
ReflectionTestUtils.setField(recruitmentInfo, "deadline", deadlineBeforeNow);
ReflectionTestUtils.setField(recruitment, "info", recruitmentInfo);
recruitmentRepository.save(recruitment);

//when
recruitmentService.autoCloseRecruitment();

//then
Recruitment findRecruitment = entityManager.find(Recruitment.class,
recruitment.getRecruitmentId());
assertThat(findRecruitment.isClosed()).isTrue();
}
}
}
Loading

0 comments on commit 2d97f55

Please sign in to comment.