-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch 'develop' into hotfix#157
- Loading branch information
Showing
17 changed files
with
502 additions
and
12 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
85 changes: 85 additions & 0 deletions
85
...ver/src/main/java/com/wootecam/festivals/domain/ticket/service/TicketScheduleService.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,85 @@ | ||
package com.wootecam.festivals.domain.ticket.service; | ||
|
||
import com.wootecam.festivals.domain.ticket.repository.TicketInfoRedisRepository; | ||
import com.wootecam.festivals.domain.ticket.repository.TicketStockRedisRepository; | ||
import com.wootecam.festivals.domain.ticket.dto.TicketResponse; | ||
import com.wootecam.festivals.domain.ticket.repository.TicketRepository; | ||
import java.time.LocalDateTime; | ||
import java.util.List; | ||
import lombok.RequiredArgsConstructor; | ||
import lombok.extern.slf4j.Slf4j; | ||
import org.springframework.context.event.ContextRefreshedEvent; | ||
import org.springframework.context.event.EventListener; | ||
import org.springframework.scheduling.annotation.Scheduled; | ||
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; | ||
import org.springframework.scheduling.support.CronTrigger; | ||
import org.springframework.stereotype.Service; | ||
|
||
@Slf4j | ||
@Service | ||
@RequiredArgsConstructor | ||
public class TicketScheduleService { | ||
|
||
private final TicketRepository ticketRepository; | ||
private final TicketInfoRedisRepository ticketInfoRedisRepository; | ||
private final ThreadPoolTaskScheduler taskScheduler; | ||
private final TicketStockRedisRepository ticketStockRedisRepository; | ||
|
||
/** | ||
* 판매 진행중이거나 앞으로 판매될 티켓의 메타 정보와 재고를 Redis에 저장 - Ticket 의 startSaleTime, endSaleTime, remainStock | ||
*/ | ||
@EventListener(ContextRefreshedEvent.class) | ||
public void scheduleRedisTicketInfoUpdate() { | ||
log.debug("Redis에 티켓 정보 업데이트 테스크 스케줄링 시작 시각 {}", LocalDateTime.now()); | ||
|
||
List<TicketResponse> tickets = ticketRepository.findUpcomingAndOngoingSaleTickets( | ||
LocalDateTime.now()); | ||
|
||
for (TicketResponse ticket : tickets) { | ||
LocalDateTime startSaleTime = ticket.startSaleTime(); | ||
// 판매 진행까지 10분 초과 남았다면 schedule | ||
if (startSaleTime.isAfter(LocalDateTime.now().plusMinutes(10))) { | ||
taskScheduler.schedule(() -> updateRedisTicketInfo(ticket), createUpdateRedisTicketCronTrigger(ticket)); | ||
taskScheduler.schedule(() -> updateRedisTicketStockCount(ticket), createUpdateRedisTicketCronTrigger(ticket)); | ||
log.debug("Redis 티켓 정보 업데이트 스케줄링 완료 - 티켓 ID: {}, 판매 시작 시각: {}, 판매 종료 시각: {}, 남은 재고: {}", ticket.id(), ticket.startSaleTime(), ticket.endSaleTime(), ticket.remainStock()); | ||
} | ||
// 그렇지 않다면 바로 업데이트 | ||
else { | ||
updateRedisTicketInfo(ticket); | ||
updateRedisTicketStockCount(ticket); | ||
log.debug("Redis 티켓 정보 즉시 업데이트 완료 - 티켓 ID: {}, 판매 시작 시각: {}, 판매 종료 시각: {}, 남은 재고: {}", ticket.id(), ticket.startSaleTime(), ticket.endSaleTime(), ticket.remainStock()); | ||
} | ||
} | ||
} | ||
|
||
private void updateRedisTicketInfo(TicketResponse ticket) { | ||
// Redis 에 티켓 정보 업데이트 (tickets:ticketId:startSaleTime, tickets:ticketId:endSaleTime) | ||
ticketInfoRedisRepository.setTicketInfo(ticket.id(), ticket.startSaleTime(), ticket.endSaleTime()); | ||
|
||
log.debug("Redis 티켓 정보 업데이트 완료 - 티켓 ID: {}, 판매 시작 시각: {}, 판매 종료 시각: {}", ticket.id(), ticket.startSaleTime(), ticket.endSaleTime()); | ||
} | ||
|
||
|
||
private void updateRedisTicketStockCount(TicketResponse ticket) { | ||
// Redis 에 남은 티켓 재고 업데이트 (tickets:ticketId:ticketStocks:count) | ||
ticketStockRedisRepository.setTicketStockCount(ticket.id(), ticket.remainStock()); | ||
|
||
log.debug("Redis에 저장된 티켓 남은 재고 count 업데이트 - 티켓 ID: {}, 남은 재고: {}", ticket.id(), ticket.remainStock()); | ||
} | ||
|
||
|
||
// 판매 시간 전이라면 판매 10분 전에 스케줄링 | ||
private CronTrigger createUpdateRedisTicketCronTrigger(TicketResponse ticket) { | ||
LocalDateTime startSaleTime = ticket.startSaleTime(); | ||
LocalDateTime scheduledTime = startSaleTime.minusMinutes(10); | ||
|
||
String cronExpression = String.format("%d %d %d %d %d ?", | ||
scheduledTime.getSecond(), | ||
scheduledTime.getMinute(), | ||
scheduledTime.getHour(), | ||
scheduledTime.getDayOfMonth(), | ||
scheduledTime.getMonthValue()); | ||
|
||
return new CronTrigger(cronExpression); | ||
} | ||
} |
36 changes: 36 additions & 0 deletions
36
backend/api-server/src/main/java/com/wootecam/festivals/global/config/RedisConfig.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
package com.wootecam.festivals.global.config; | ||
|
||
|
||
import org.springframework.beans.factory.annotation.Value; | ||
import org.springframework.context.annotation.Bean; | ||
import org.springframework.context.annotation.Configuration; | ||
import org.springframework.data.redis.connection.RedisConnectionFactory; | ||
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; | ||
import org.springframework.data.redis.core.RedisTemplate; | ||
import org.springframework.data.redis.serializer.StringRedisSerializer; | ||
|
||
@Configuration | ||
public class RedisConfig { | ||
|
||
@Value("${spring.data.redis.host}") | ||
private String redisHost; | ||
|
||
@Value("${spring.data.redis.port}") | ||
private int redisPort; | ||
|
||
@Bean | ||
public RedisConnectionFactory redisConnectionFactory() { | ||
return new LettuceConnectionFactory(redisHost, redisPort); | ||
} | ||
|
||
@Bean | ||
public RedisTemplate<String, String> redisTemplate() { | ||
RedisTemplate<String, String> redisTemplate = new RedisTemplate<>(); | ||
redisTemplate.setConnectionFactory(redisConnectionFactory()); | ||
redisTemplate.setKeySerializer(new StringRedisSerializer()); | ||
redisTemplate.setValueSerializer(new StringRedisSerializer()); | ||
redisTemplate.setHashKeySerializer(new StringRedisSerializer()); | ||
redisTemplate.setHashValueSerializer(new StringRedisSerializer()); | ||
return redisTemplate; | ||
} | ||
} |
152 changes: 152 additions & 0 deletions
152
...src/test/java/com/wootecam/festivals/domain/ticket/service/TicketScheduleServiceTest.java
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,152 @@ | ||
package com.wootecam.festivals.domain.ticket.service; | ||
|
||
import static org.assertj.core.api.Assertions.assertThat; | ||
import static com.wootecam.festivals.domain.ticket.service.TicketScheduleServiceTestFixture.*; | ||
import static org.assertj.core.api.Assertions.within; | ||
|
||
import com.wootecam.festivals.domain.festival.entity.Festival; | ||
import com.wootecam.festivals.domain.festival.repository.FestivalRepository; | ||
import com.wootecam.festivals.domain.member.entity.Member; | ||
import com.wootecam.festivals.domain.member.repository.MemberRepository; | ||
import com.wootecam.festivals.domain.ticket.entity.TicketInfo; | ||
import com.wootecam.festivals.domain.ticket.repository.TicketInfoRedisRepository; | ||
import com.wootecam.festivals.domain.ticket.entity.Ticket; | ||
import com.wootecam.festivals.domain.ticket.entity.TicketStock; | ||
import com.wootecam.festivals.domain.ticket.repository.TicketRepository; | ||
import com.wootecam.festivals.domain.ticket.repository.TicketStockRedisRepository; | ||
import com.wootecam.festivals.domain.ticket.repository.TicketStockRepository; | ||
import com.wootecam.festivals.utils.SpringBootTestConfig; | ||
import java.time.temporal.ChronoUnit; | ||
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.boot.test.context.SpringBootTest; | ||
|
||
import java.util.List; | ||
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; | ||
|
||
@SpringBootTest | ||
@DisplayName("TicketScheduleService 통합 테스트") | ||
class TicketScheduleServiceTest extends SpringBootTestConfig { | ||
|
||
@Autowired | ||
private ThreadPoolTaskScheduler taskScheduler; | ||
|
||
@Autowired | ||
private TicketScheduleService ticketScheduleService; | ||
|
||
@Autowired | ||
private TicketInfoRedisRepository ticketInfoRedisRepository; | ||
|
||
@Autowired | ||
private TicketRepository ticketRepository; | ||
|
||
@Autowired | ||
private FestivalRepository festivalRepository; | ||
|
||
@Autowired | ||
private MemberRepository memberRepository; | ||
|
||
@Autowired | ||
private TicketStockRepository ticketStockRepository; | ||
|
||
@Autowired | ||
private TicketStockRedisRepository ticketStockRedisRepository; | ||
|
||
private List<Ticket> saleUpcomingTicketsWithinTenMinutes; | ||
private List<Ticket> saleUpcomingTicketsAfterTenMinutes; | ||
private List<Ticket> saleOngoingTickets; | ||
private Festival festival; | ||
private int saleUpcomingTicketsWithinTenMinutesCount = 4; | ||
private int saleUpcomingTicketsAfterTenMinutesCount = 5; | ||
private int saleOngoingTicketsCount = 6; | ||
|
||
@BeforeEach | ||
void setUp() { | ||
clear(); | ||
Member admin = createMembers(1).get(0); | ||
memberRepository.save(admin); | ||
|
||
festival = createUpcomingFestival(admin); | ||
festivalRepository.save(festival); | ||
|
||
saleUpcomingTicketsWithinTenMinutes = createSaleUpcomingTicketsWithinTenMinutes(saleUpcomingTicketsWithinTenMinutesCount, festival); | ||
saleUpcomingTicketsAfterTenMinutes = createSaleUpcomingTicketsAfterTenMinutes(saleUpcomingTicketsAfterTenMinutesCount, festival); | ||
saleOngoingTickets = createSaleOngoingTickets(saleOngoingTicketsCount, festival); | ||
|
||
ticketRepository.saveAll(saleUpcomingTicketsWithinTenMinutes); | ||
ticketRepository.saveAll(saleUpcomingTicketsAfterTenMinutes); | ||
ticketRepository.saveAll(saleOngoingTickets); | ||
|
||
List<TicketStock> saleUpcomingTicketStocksWithinTenMinutes = saleUpcomingTicketsWithinTenMinutes.stream() | ||
.flatMap(ticket -> ticket.createTicketStock().stream()) | ||
.toList(); | ||
List<TicketStock> saleUpcomingTicketStocksAfterTenMinutes = saleUpcomingTicketsAfterTenMinutes.stream() | ||
.flatMap(ticket -> ticket.createTicketStock().stream()) | ||
.toList(); | ||
List<TicketStock> saleOngoingTicketStocks = saleOngoingTickets.stream() | ||
.flatMap(ticket -> ticket.createTicketStock().stream()) | ||
.toList(); | ||
|
||
ticketStockRepository.saveAll(saleUpcomingTicketStocksWithinTenMinutes); | ||
ticketStockRepository.saveAll(saleUpcomingTicketStocksAfterTenMinutes); | ||
ticketStockRepository.saveAll(saleOngoingTicketStocks); | ||
|
||
taskScheduler.getScheduledThreadPoolExecutor().getQueue().clear(); | ||
} | ||
|
||
@Nested | ||
@DisplayName("scheduleRedisTicketInfoUpdate 메소드는") | ||
class DescribeScheduleRedisTicketInfoUpdate { | ||
|
||
@Test | ||
@DisplayName("10분 이내에 판매 시작되는 티켓은 Redis에 즉시 업데이트하고, 나머지는 스케줄링한다") | ||
void itUpdatesAndSchedulesTicketsCorrectly() { | ||
// When | ||
ticketScheduleService.scheduleRedisTicketInfoUpdate(); | ||
|
||
// Then | ||
saleUpcomingTicketsWithinTenMinutes.forEach(ticket -> { | ||
TicketInfo ticketInfo = ticketInfoRedisRepository.getTicketInfo(ticket.getId()); | ||
assertThat(ticketInfo).isNotNull(); | ||
assertThat(ticketInfo.startSaleTime()).isCloseTo(ticket.getStartSaleTime(), within(10, ChronoUnit.SECONDS)); | ||
assertThat(ticketInfo.endSaleTime()).isCloseTo(ticket.getEndSaleTime(), within(10, ChronoUnit.SECONDS)); | ||
}); | ||
|
||
saleUpcomingTicketsAfterTenMinutes.forEach(ticket -> { | ||
TicketInfo ticketInfo = ticketInfoRedisRepository.getTicketInfo(ticket.getId()); | ||
assertThat(ticketInfo).isNull(); | ||
}); | ||
|
||
saleOngoingTickets.forEach(ticket -> { | ||
TicketInfo ticketInfo = ticketInfoRedisRepository.getTicketInfo(ticket.getId()); | ||
assertThat(ticketInfo).isNotNull(); | ||
assertThat(ticketInfo.startSaleTime()).isCloseTo(ticket.getStartSaleTime(), within(10, ChronoUnit.SECONDS)); | ||
assertThat(ticketInfo.endSaleTime()).isCloseTo(ticket.getEndSaleTime(), within(10, ChronoUnit.SECONDS)); | ||
}); | ||
|
||
// 티켓 재고와 티켓 판매 시각 정보 둘다 스케줄링 되기 때문에 2배 | ||
assertThat(taskScheduler.getScheduledThreadPoolExecutor().getQueue()).hasSize(saleUpcomingTicketsAfterTenMinutesCount * 2); | ||
} | ||
|
||
@Test | ||
@DisplayName("정확히 10분 뒤에 판매 시작되는 티켓은 즉시 업데이트한다") | ||
void testUpdateTicketInfoImmediately() { | ||
// Given | ||
Ticket ticket = createSaleUpcomingTicketsExactlyTenMinutes(1, festival).get(0); | ||
ticketRepository.save(ticket); | ||
ticketStockRepository.saveAll(ticket.createTicketStock()); | ||
|
||
// When | ||
ticketScheduleService.scheduleRedisTicketInfoUpdate(); | ||
|
||
// Then | ||
TicketInfo ticketInfo = ticketInfoRedisRepository.getTicketInfo(ticket.getId()); | ||
assertThat(ticketInfo).isNotNull(); | ||
assertThat(ticketInfo.startSaleTime()).isCloseTo(ticket.getStartSaleTime(), within(10, ChronoUnit.SECONDS)); | ||
assertThat(ticketInfo.endSaleTime()).isCloseTo(ticket.getEndSaleTime(), within(10, ChronoUnit.SECONDS)); | ||
} | ||
} | ||
} |
Oops, something went wrong.