Skip to content

Commit

Permalink
Merge branch 'develop' into hotfix#157
Browse files Browse the repository at this point in the history
  • Loading branch information
bellringstar authored Aug 28, 2024
2 parents 0aa7470 + 2b7e675 commit 6d1f791
Show file tree
Hide file tree
Showing 17 changed files with 502 additions and 12 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.wootecam.festivals.domain.ticket.dto.TicketResponse;
import com.wootecam.festivals.domain.ticket.entity.Ticket;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
Expand All @@ -16,7 +17,7 @@ public interface TicketRepository extends JpaRepository<Ticket, Long> {
@Query("""
SELECT new com.wootecam.festivals.domain.ticket.dto.TicketResponse(
t.id, t.name, t.detail, t.price, t.quantity,
(SELECT count(ts.id) FROM TicketStock ts WHERE ts.ticket.id = t.id AND ts.memberId IS NOT NULL),
(SELECT count(ts.id) FROM TicketStock ts WHERE ts.ticket.id = t.id AND ts.memberId IS NULL),
t.startSaleTime, t.endSaleTime, t.refundEndTime, t.createdAt, t.updatedAt
)
FROM Ticket t
Expand All @@ -26,4 +27,16 @@ public interface TicketRepository extends JpaRepository<Ticket, Long> {

@Query("SELECT t FROM Ticket t join fetch t.festival WHERE t.id = :ticketId AND t.festival.id = :festivalId AND t.isDeleted = false")
Optional<Ticket> findByIdAndFestivalId(Long ticketId, Long festivalId);

@Query("""
SELECT new com.wootecam.festivals.domain.ticket.dto.TicketResponse(
t.id, t.name, t.detail, t.price, t.quantity,
(SELECT count(ts.id) FROM TicketStock ts WHERE ts.ticket.id = t.id AND ts.memberId IS NULL),
t.startSaleTime, t.endSaleTime, t.refundEndTime, t.createdAt, t.updatedAt
)
FROM Ticket t
WHERE t.startSaleTime >= :now OR (t.startSaleTime <= :now AND t.endSaleTime >= :now)
AND t.isDeleted = false
""")
List<TicketResponse> findUpcomingAndOngoingSaleTickets(LocalDateTime now);
}
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);
}
}
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;
}
}
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));
}
}
}
Loading

0 comments on commit 6d1f791

Please sign in to comment.