diff --git a/backend/src/main/java/com/festago/ticket/application/command/TicketMaxReserveCountChangeService.java b/backend/src/main/java/com/festago/ticket/application/command/TicketMaxReserveCountChangeService.java new file mode 100644 index 000000000..701836dd1 --- /dev/null +++ b/backend/src/main/java/com/festago/ticket/application/command/TicketMaxReserveCountChangeService.java @@ -0,0 +1,23 @@ +package com.festago.ticket.application.command; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.ticket.domain.NewTicket; +import com.festago.ticket.repository.NewTicketRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class TicketMaxReserveCountChangeService { + + private final NewTicketRepository ticketRepository; + + public void changeMaxReserveAmount(Long ticketId, int maxReserveAmount) { + NewTicket ticket = ticketRepository.findById(ticketId) + .orElseThrow(() -> new NotFoundException(ErrorCode.TICKET_NOT_FOUND)); + ticket.changeMaxReserveAmount(maxReserveAmount); + } +} diff --git a/backend/src/main/java/com/festago/ticket/domain/validator/stage/ExistsTicketStageDeleteValidator.java b/backend/src/main/java/com/festago/ticket/domain/validator/stage/ExistsTicketStageDeleteValidator.java new file mode 100644 index 000000000..095adfbcb --- /dev/null +++ b/backend/src/main/java/com/festago/ticket/domain/validator/stage/ExistsTicketStageDeleteValidator.java @@ -0,0 +1,23 @@ +package com.festago.ticket.domain.validator.stage; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.stage.domain.Stage; +import com.festago.stage.domain.validator.StageDeleteValidator; +import com.festago.ticket.repository.StageTicketRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ExistsTicketStageDeleteValidator implements StageDeleteValidator { + + private final StageTicketRepository stageTicketRepository; + + @Override + public void validate(Stage stage) { + if (stageTicketRepository.existsByStageId(stage.getId())) { + throw new BadRequestException(ErrorCode.STAGE_DELETE_CONSTRAINT_EXISTS_TICKET); + } + } +} diff --git a/backend/src/main/java/com/festago/ticket/domain/validator/stage/ExistsTicketStageUpdateValidator.java b/backend/src/main/java/com/festago/ticket/domain/validator/stage/ExistsTicketStageUpdateValidator.java new file mode 100644 index 000000000..c3ccbd320 --- /dev/null +++ b/backend/src/main/java/com/festago/ticket/domain/validator/stage/ExistsTicketStageUpdateValidator.java @@ -0,0 +1,23 @@ +package com.festago.ticket.domain.validator.stage; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.stage.domain.Stage; +import com.festago.stage.domain.validator.StageUpdateValidator; +import com.festago.ticket.repository.StageTicketRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ExistsTicketStageUpdateValidator implements StageUpdateValidator { + + private final StageTicketRepository stageTicketRepository; + + @Override + public void validate(Stage stage) { + if (stageTicketRepository.existsByStageId(stage.getId())) { + throw new BadRequestException(ErrorCode.STAGE_UPDATE_CONSTRAINT_EXISTS_TICKET); + } + } +} diff --git a/backend/src/main/java/com/festago/ticket/repository/NewTicketDao.java b/backend/src/main/java/com/festago/ticket/repository/NewTicketDao.java new file mode 100644 index 000000000..aa5303780 --- /dev/null +++ b/backend/src/main/java/com/festago/ticket/repository/NewTicketDao.java @@ -0,0 +1,24 @@ +package com.festago.ticket.repository; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.ticket.domain.NewTicket; +import com.festago.ticket.domain.NewTicketType; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +// TODO NewTicket -> Ticket 이름 변경할 것 +@Repository +@RequiredArgsConstructor +public class NewTicketDao { + + private final StageTicketRepository stageTicketRepository; + + public NewTicket findByIdWithTicketTypeAndFetch(Long id, NewTicketType ticketType) { + Optional ticket = switch (ticketType) { + case STAGE -> stageTicketRepository.findByIdWithFetch(id); + }; + return ticket.orElseThrow(() -> new NotFoundException(ErrorCode.TICKET_NOT_FOUND)); + } +} diff --git a/backend/src/main/java/com/festago/ticket/repository/NewTicketRepository.java b/backend/src/main/java/com/festago/ticket/repository/NewTicketRepository.java new file mode 100644 index 000000000..946598184 --- /dev/null +++ b/backend/src/main/java/com/festago/ticket/repository/NewTicketRepository.java @@ -0,0 +1,13 @@ +package com.festago.ticket.repository; + +import com.festago.ticket.domain.NewTicket; +import java.util.Optional; +import org.springframework.data.repository.Repository; + +// TODO NewTicket -> Ticket 이름 변경할 것 +public interface NewTicketRepository extends Repository { + + NewTicket save(NewTicket ticket); + + Optional findById(Long id); +} diff --git a/backend/src/main/java/com/festago/ticketing/application/TicketQuantityEventListener.java b/backend/src/main/java/com/festago/ticketing/application/TicketQuantityEventListener.java new file mode 100644 index 000000000..ac4eaa874 --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/application/TicketQuantityEventListener.java @@ -0,0 +1,32 @@ +package com.festago.ticketing.application; + +import com.festago.ticket.domain.NewTicket; +import com.festago.ticket.dto.event.TicketCreatedEvent; +import com.festago.ticket.dto.event.TicketDeletedEvent; +import com.festago.ticketing.application.command.TicketQuantityUpdateService; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class TicketQuantityEventListener { + + private final TicketQuantityUpdateService ticketQuantityUpdateService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void ticketCreatedEventHandler(TicketCreatedEvent event) { + NewTicket ticket = event.ticket(); + ticketQuantityUpdateService.putOrDeleteTicketQuantity(ticket); + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void ticketDeletedEventHandler(TicketDeletedEvent event) { + NewTicket ticket = event.ticket(); + ticketQuantityUpdateService.putOrDeleteTicketQuantity(ticket); + } +} diff --git a/backend/src/main/java/com/festago/ticketing/application/command/QuantityTicketingService.java b/backend/src/main/java/com/festago/ticketing/application/command/QuantityTicketingService.java new file mode 100644 index 000000000..c95fcfa6c --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/application/command/QuantityTicketingService.java @@ -0,0 +1,51 @@ +package com.festago.ticketing.application.command; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.ticketing.domain.Booker; +import com.festago.ticketing.domain.TicketQuantity; +import com.festago.ticketing.domain.TicketingRateLimiter; +import com.festago.ticketing.dto.TicketingResult; +import com.festago.ticketing.dto.command.TicketingCommand; +import com.festago.ticketing.repository.TicketQuantityRepository; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +//@Transactional 명시적으로 Transactional 사용하지 않음 +public class QuantityTicketingService { + + private final TicketQuantityRepository ticketQuantityRepository; + private final TicketingCommandService ticketingCommandService; + private final TicketingRateLimiter ticketingRateLimiter; + + public TicketingResult ticketing(TicketingCommand command) { + TicketQuantity ticketQuantity = getTicketQuantity(command.ticketId()); + validateFrequentTicketing(command.booker()); + try { + ticketQuantity.decreaseQuantity(); + return ticketingCommandService.reserveTicket(command); + } catch (Exception e) { + ticketQuantity.increaseQuantity(); + throw e; + } + } + + private TicketQuantity getTicketQuantity(Long ticketId) { + TicketQuantity ticketQuantity = ticketQuantityRepository.findByTicketId(ticketId) + .orElseThrow(() -> new NotFoundException(ErrorCode.TICKET_NOT_FOUND)); + if (ticketQuantity.isSoldOut()) { + throw new BadRequestException(ErrorCode.TICKET_SOLD_OUT); + } + return ticketQuantity; + } + + private void validateFrequentTicketing(Booker booker) { + if (ticketingRateLimiter.isFrequentTicketing(booker, 5, TimeUnit.SECONDS)) { + throw new BadRequestException(ErrorCode.TOO_FREQUENT_REQUESTS); + } + } +} diff --git a/backend/src/main/java/com/festago/ticketing/application/command/TicketQuantityUpdateService.java b/backend/src/main/java/com/festago/ticketing/application/command/TicketQuantityUpdateService.java new file mode 100644 index 000000000..2711898fa --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/application/command/TicketQuantityUpdateService.java @@ -0,0 +1,21 @@ +package com.festago.ticketing.application.command; + +import com.festago.ticket.domain.NewTicket; +import com.festago.ticketing.repository.TicketQuantityRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class TicketQuantityUpdateService { + + private final TicketQuantityRepository ticketQuantityRepository; + + public void putOrDeleteTicketQuantity(NewTicket ticket) { + if (ticket.isEmptyAmount()) { + ticketQuantityRepository.delete(ticket); + } else { + ticketQuantityRepository.put(ticket); + } + } +} diff --git a/backend/src/main/java/com/festago/ticketing/application/command/TicketingCommandService.java b/backend/src/main/java/com/festago/ticketing/application/command/TicketingCommandService.java new file mode 100644 index 000000000..c36bf6f83 --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/application/command/TicketingCommandService.java @@ -0,0 +1,9 @@ +package com.festago.ticketing.application.command; + +import com.festago.ticketing.dto.TicketingResult; +import com.festago.ticketing.dto.command.TicketingCommand; + +public interface TicketingCommandService { + + TicketingResult reserveTicket(TicketingCommand command); +} diff --git a/backend/src/main/java/com/festago/ticketing/application/command/TicketingCommandServiceImpl.java b/backend/src/main/java/com/festago/ticketing/application/command/TicketingCommandServiceImpl.java new file mode 100644 index 000000000..d08ba5d85 --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/application/command/TicketingCommandServiceImpl.java @@ -0,0 +1,54 @@ +package com.festago.ticketing.application.command; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.ticket.domain.NewTicket; +import com.festago.ticket.domain.NewTicketType; +import com.festago.ticket.repository.NewTicketDao; +import com.festago.ticketing.domain.Booker; +import com.festago.ticketing.domain.ReserveTicket; +import com.festago.ticketing.domain.TicketingSequenceGenerator; +import com.festago.ticketing.domain.validator.TicketingValidator; +import com.festago.ticketing.dto.TicketingResult; +import com.festago.ticketing.dto.command.TicketingCommand; +import com.festago.ticketing.repository.ReserveTicketRepository; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +@RequiredArgsConstructor +public class TicketingCommandServiceImpl implements TicketingCommandService { + + private final NewTicketDao ticketDao; + private final ReserveTicketRepository reserveTicketRepository; + private final TicketingSequenceGenerator sequenceGenerator; + private final List validators; + private final Clock clock; + + @Override + public TicketingResult reserveTicket(TicketingCommand command) { + Long ticketId = command.ticketId(); + NewTicketType ticketType = command.ticketType(); + NewTicket ticket = ticketDao.findByIdWithTicketTypeAndFetch(ticketId, ticketType); + Booker booker = command.booker(); + ticket.validateReserve(booker, LocalDateTime.now(clock)); + validators.forEach(validator -> validator.validate(ticket, booker)); + validate(ticket, booker); + int sequence = sequenceGenerator.generate(ticketId); + ReserveTicket reserveTicket = ticket.reserve(booker, sequence); + reserveTicketRepository.save(ticket.reserve(booker, sequence)); + return new TicketingResult(reserveTicket.getTicketId()); + } + + private void validate(NewTicket ticket, Booker booker) { + long reserveCount = reserveTicketRepository.countByMemberIdAndTicketId(booker.getMemberId(), ticket.getId()); + if (reserveCount >= ticket.getMaxReserveAmount()) { + throw new BadRequestException(ErrorCode.RESERVE_TICKET_OVER_AMOUNT); + } + } +} diff --git a/backend/src/main/java/com/festago/ticketing/domain/TicketQuantity.java b/backend/src/main/java/com/festago/ticketing/domain/TicketQuantity.java new file mode 100644 index 000000000..474cef66c --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/domain/TicketQuantity.java @@ -0,0 +1,36 @@ +package com.festago.ticketing.domain; + +import com.festago.common.exception.BadRequestException; + +/** + * 티켓의 재고를 관리하는 도메인
해당 도메인을 구현하는 구현체는 반드시 원자적인 연산을 사용해야 한다.
+ */ +public interface TicketQuantity { + + /** + * 티켓의 매진 여부를 반환한다. + * + * @return 티켓이 매진이면 true, 매진이 아니면 false + */ + boolean isSoldOut(); + + /** + * 티켓의 재고를 하나 감소시킨다.
해당 메서드의 연산은 atomic 해야 한다.
감소 시킨 뒤 값이 음수인 경우에는 매진이 된 상태에 요청이 들어온 것이므로, 예외를 던져야 한다. + *
+ * + * @throws BadRequestException 감소 시킨 뒤 값이 음수이면 + */ + void decreaseQuantity() throws BadRequestException; + + /** + * 티켓의 재고를 하나 증가시킨다.
해당 메서드의 연산은 atomic 해야 한다.
+ */ + void increaseQuantity(); + + /** + * 티켓의 남은 재고를 반환한다.
+ * + * @return 티켓의 남은 재고 + */ + int getQuantity(); +} diff --git a/backend/src/main/java/com/festago/ticketing/domain/TicketingRateLimiter.java b/backend/src/main/java/com/festago/ticketing/domain/TicketingRateLimiter.java new file mode 100644 index 000000000..9d736af64 --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/domain/TicketingRateLimiter.java @@ -0,0 +1,8 @@ +package com.festago.ticketing.domain; + +import java.util.concurrent.TimeUnit; + +public interface TicketingRateLimiter { + + boolean isFrequentTicketing(Booker booker, long timeout, TimeUnit unit); +} diff --git a/backend/src/main/java/com/festago/ticketing/domain/TicketingSequenceGenerator.java b/backend/src/main/java/com/festago/ticketing/domain/TicketingSequenceGenerator.java new file mode 100644 index 000000000..226ec5d1c --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/domain/TicketingSequenceGenerator.java @@ -0,0 +1,6 @@ +package com.festago.ticketing.domain; + +public interface TicketingSequenceGenerator { + + int generate(Long ticketId); +} diff --git a/backend/src/main/java/com/festago/ticketing/domain/validator/StageReservedTicketIdResolver.java b/backend/src/main/java/com/festago/ticketing/domain/validator/StageReservedTicketIdResolver.java new file mode 100644 index 000000000..2930e400b --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/domain/validator/StageReservedTicketIdResolver.java @@ -0,0 +1,17 @@ +package com.festago.ticketing.domain.validator; + +import jakarta.annotation.Nullable; + +public interface StageReservedTicketIdResolver { + + /** + * 사용자가 StageTicket에 대해 예매한 티켓의 식별자를 반환합니다.
예매한 이력이 없으면 null이 반환되고, 예매한 이력이 있으면 사용자가 예매했던 티켓의 식별자를 반환합니다. + *
+ * + * @param memberId 티켓을 예매한 사용자의 식별자 + * @param stageId StageTicket의 Stage 식별자 + * @return 예매한 이력이 없으면 null, 예매한 이력이 있으면 티켓 식별자 반환 + */ + @Nullable + Long resolve(Long memberId, Long stageId); +} diff --git a/backend/src/main/java/com/festago/ticketing/domain/validator/StageSingleTicketTypeTicketingValidator.java b/backend/src/main/java/com/festago/ticketing/domain/validator/StageSingleTicketTypeTicketingValidator.java new file mode 100644 index 000000000..e50faa139 --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/domain/validator/StageSingleTicketTypeTicketingValidator.java @@ -0,0 +1,34 @@ +package com.festago.ticketing.domain.validator; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.ticket.domain.NewTicket; +import com.festago.ticket.domain.StageTicket; +import com.festago.ticketing.domain.Booker; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 공연의 티켓에 대해 하나의 유형에 대해서만 예매가 가능하도록 검증하는 클래스
ex) 사용자가 하나의 공연에 대해 학생 전용, 외부인 전용을 모두 예매하는 상황을 방지
+ */ +@Component +@RequiredArgsConstructor +public class StageSingleTicketTypeTicketingValidator implements TicketingValidator { + + private final StageReservedTicketIdResolver stageReservedTicketIdResolver; + + @Override + public void validate(NewTicket ticket, Booker booker) { + if (!(ticket instanceof StageTicket stageTicket)) { + return; + } + Long memberId = booker.getMemberId(); + Long stageId = stageTicket.getStage().getId(); + Long ticketId = stageReservedTicketIdResolver.resolve(memberId, stageId); + if (ticketId == null || Objects.equals(ticketId, ticket.getId())) { + return; + } + throw new BadRequestException(ErrorCode.ONLY_STAGE_TICKETING_SINGLE_TYPE); + } +} diff --git a/backend/src/main/java/com/festago/ticketing/domain/validator/TicketingValidator.java b/backend/src/main/java/com/festago/ticketing/domain/validator/TicketingValidator.java new file mode 100644 index 000000000..c37156be9 --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/domain/validator/TicketingValidator.java @@ -0,0 +1,9 @@ +package com.festago.ticketing.domain.validator; + +import com.festago.ticket.domain.NewTicket; +import com.festago.ticketing.domain.Booker; + +public interface TicketingValidator { + + void validate(NewTicket ticket, Booker booker); +} diff --git a/backend/src/main/java/com/festago/ticketing/dto/TicketingResult.java b/backend/src/main/java/com/festago/ticketing/dto/TicketingResult.java new file mode 100644 index 000000000..05f9c43a9 --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/dto/TicketingResult.java @@ -0,0 +1,7 @@ +package com.festago.ticketing.dto; + +public record TicketingResult( + Long reserveTicketId +) { + +} diff --git a/backend/src/main/java/com/festago/ticketing/dto/command/TicketingCommand.java b/backend/src/main/java/com/festago/ticketing/dto/command/TicketingCommand.java new file mode 100644 index 000000000..4dda73f96 --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/dto/command/TicketingCommand.java @@ -0,0 +1,14 @@ +package com.festago.ticketing.dto.command; + +import com.festago.ticket.domain.NewTicketType; +import com.festago.ticketing.domain.Booker; +import lombok.Builder; + +@Builder +public record TicketingCommand( + Long ticketId, + NewTicketType ticketType, + Booker booker +) { + +} diff --git a/backend/src/main/java/com/festago/ticketing/infrastructure/RedisTicketingRateLimiter.java b/backend/src/main/java/com/festago/ticketing/infrastructure/RedisTicketingRateLimiter.java new file mode 100644 index 000000000..8dffa0a07 --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/infrastructure/RedisTicketingRateLimiter.java @@ -0,0 +1,31 @@ +package com.festago.ticketing.infrastructure; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.InternalServerException; +import com.festago.ticketing.domain.Booker; +import com.festago.ticketing.domain.TicketingRateLimiter; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RedisTicketingRateLimiter implements TicketingRateLimiter { + + private static final String KEY_PREFIX = "ticketing_limiter_"; + private final RedisTemplate redisTemplate; + + @Override + public boolean isFrequentTicketing(Booker booker, long timeout, TimeUnit unit) { + if (timeout <= 0) { + return false; + } + String key = KEY_PREFIX + booker.getMemberId(); + Boolean result = redisTemplate.opsForValue().setIfAbsent(key, "1", timeout, unit); + if (result == null) { + throw new InternalServerException(ErrorCode.REDIS_ERROR); + } + return !result; + } +} diff --git a/backend/src/main/java/com/festago/ticketing/infrastructure/RedisTicketingSequenceEventListener.java b/backend/src/main/java/com/festago/ticketing/infrastructure/RedisTicketingSequenceEventListener.java new file mode 100644 index 000000000..fad88d1ac --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/infrastructure/RedisTicketingSequenceEventListener.java @@ -0,0 +1,31 @@ +package com.festago.ticketing.infrastructure; + +import com.festago.ticket.domain.NewTicket; +import com.festago.ticket.dto.event.TicketCreatedEvent; +import com.festago.ticket.dto.event.TicketDeletedEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class RedisTicketingSequenceEventListener { + + private final RedisTicketingSequenceGenerator redisTicketingSequenceGenerator; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void ticketCreatedEventHandler(TicketCreatedEvent event) { + NewTicket ticket = event.ticket(); + redisTicketingSequenceGenerator.setUp(ticket.getId(), ticket.getTicketingEndTime()); + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void ticketDeletedEventHandler(TicketDeletedEvent event) { + NewTicket ticket = event.ticket(); + redisTicketingSequenceGenerator.delete(ticket.getId()); + } +} diff --git a/backend/src/main/java/com/festago/ticketing/infrastructure/RedisTicketingSequenceGenerator.java b/backend/src/main/java/com/festago/ticketing/infrastructure/RedisTicketingSequenceGenerator.java new file mode 100644 index 000000000..d99df993c --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/infrastructure/RedisTicketingSequenceGenerator.java @@ -0,0 +1,51 @@ +package com.festago.ticketing.infrastructure; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.InternalServerException; +import com.festago.ticketing.domain.TicketingSequenceGenerator; +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class RedisTicketingSequenceGenerator implements TicketingSequenceGenerator { + + private static final String KEY_PREFIX = "ticketing_seq_"; + private final RedisTemplate redisTemplate; + private final Clock clock; + + /** + * TTL을 설정하기 위한 메서드
RedisTicketingSequenceEventListener에서 호출하도록 설계했으니, 특별한 이유가 아니면 직접 호출하는 것을 금지함
+ * + * @param ticketId TTL을 설정할 티켓의 식별자 + * @param invalidateAt 만료될 시간 + */ + protected void setUp(Long ticketId, LocalDateTime invalidateAt) { + LocalDateTime now = LocalDateTime.now(clock); + Duration ttl = Duration.between(now, invalidateAt); + redisTemplate.opsForValue().set(KEY_PREFIX + ticketId, "0", ttl); + } + + /** + * 레디스에 저장된 시퀀스를 삭제하기 위한 메서드
RedisTicketingSequenceEventListener에서 호출하도록 설계했으니, 특별한 이유가 아니면 직접 호출하는 것을 금지함 + *
+ * + * @param ticketId 삭제할 티켓의 식별자 + */ + protected void delete(Long ticketId) { + redisTemplate.delete(KEY_PREFIX + ticketId); + } + + @Override + public int generate(Long ticketId) { + Long sequence = redisTemplate.opsForValue().increment(KEY_PREFIX + ticketId); + if (sequence == null) { + throw new InternalServerException(ErrorCode.REDIS_ERROR); + } + return sequence.intValue(); + } +} diff --git a/backend/src/main/java/com/festago/ticketing/infrastructure/quantity/RedisTicketQuantity.java b/backend/src/main/java/com/festago/ticketing/infrastructure/quantity/RedisTicketQuantity.java new file mode 100644 index 000000000..1733451e0 --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/infrastructure/quantity/RedisTicketQuantity.java @@ -0,0 +1,56 @@ +package com.festago.ticketing.infrastructure.quantity; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.InternalServerException; +import com.festago.common.exception.NotFoundException; +import com.festago.ticketing.domain.TicketQuantity; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; + +@Slf4j +@RequiredArgsConstructor +public class RedisTicketQuantity implements TicketQuantity { + + private final String key; + private final RedisTemplate redisTemplate; + + @Override + public boolean isSoldOut() { + String quantity = redisTemplate.opsForValue().get(key); + if (quantity == null) { + log.warn("존재하지 않는 티켓에 대한 요청이 발생했습니다. key={}", key); + throw new NotFoundException(ErrorCode.TICKET_NOT_FOUND); + } + return Integer.parseInt(quantity) <= 0; + } + + @Override + public void decreaseQuantity() { + Long quantity = redisTemplate.opsForValue().decrement(key); + if (quantity == null) { + throw new InternalServerException(ErrorCode.REDIS_ERROR); + } + if (quantity < 0) { + throw new BadRequestException(ErrorCode.TICKET_SOLD_OUT); + } + } + + @Override + public void increaseQuantity() { + Long quantity = redisTemplate.opsForValue().increment(key); + if (quantity == null) { + throw new InternalServerException(ErrorCode.REDIS_ERROR); + } + } + + @Override + public int getQuantity() { + String quantity = redisTemplate.opsForValue().get(key); + if (quantity == null) { + throw new NotFoundException(ErrorCode.TICKET_NOT_FOUND); + } + return Integer.parseInt(quantity); + } +} diff --git a/backend/src/main/java/com/festago/ticketing/infrastructure/quantity/RedisTicketQuantityRepository.java b/backend/src/main/java/com/festago/ticketing/infrastructure/quantity/RedisTicketQuantityRepository.java new file mode 100644 index 000000000..8e7d76b6f --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/infrastructure/quantity/RedisTicketQuantityRepository.java @@ -0,0 +1,41 @@ +package com.festago.ticketing.infrastructure.quantity; + +import com.festago.ticket.domain.NewTicket; +import com.festago.ticketing.domain.TicketQuantity; +import com.festago.ticketing.repository.TicketQuantityRepository; +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class RedisTicketQuantityRepository implements TicketQuantityRepository { + + private static final String KEY_PREFIX = "ticket_quantity_"; + private final RedisTemplate redisTemplate; + private final Clock clock; + + @Override + public TicketQuantity put(NewTicket ticket) { + String key = KEY_PREFIX + ticket.getId(); + int quantity = ticket.getAmount(); + LocalDateTime now = LocalDateTime.now(clock); + Duration ttl = Duration.between(now, ticket.getTicketingEndTime()); + redisTemplate.opsForValue().set(key, String.valueOf(quantity), ttl); + return new RedisTicketQuantity(key, redisTemplate); + } + + @Override + public Optional findByTicketId(Long ticketId) { + return Optional.of(new RedisTicketQuantity(KEY_PREFIX + ticketId, redisTemplate)); + } + + @Override + public void delete(NewTicket ticket) { + redisTemplate.delete(KEY_PREFIX + ticket.getId()); + } +} diff --git a/backend/src/main/java/com/festago/ticketing/infrastructure/validator/StageReservedTicketIdResolverImpl.java b/backend/src/main/java/com/festago/ticketing/infrastructure/validator/StageReservedTicketIdResolverImpl.java new file mode 100644 index 000000000..765c6ba8c --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/infrastructure/validator/StageReservedTicketIdResolverImpl.java @@ -0,0 +1,25 @@ +package com.festago.ticketing.infrastructure.validator; + +import static com.festago.ticket.domain.QStageTicket.stageTicket; +import static com.festago.ticketing.domain.QReserveTicket.reserveTicket; + +import com.festago.common.querydsl.QueryDslHelper; +import com.festago.ticketing.domain.validator.StageReservedTicketIdResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class StageReservedTicketIdResolverImpl implements StageReservedTicketIdResolver { + + private final QueryDslHelper queryDslHelper; + + @Override + public Long resolve(Long memberId, Long stageId) { + return queryDslHelper.select(stageTicket.id) + .from(stageTicket) + .join(reserveTicket).on(reserveTicket.ticketId.eq(stageTicket.id)) + .where(reserveTicket.memberId.eq(memberId).and(stageTicket.stage.id.eq(stageId))) + .fetchFirst(); + } +} diff --git a/backend/src/main/java/com/festago/ticketing/repository/ReserveTicketRepository.java b/backend/src/main/java/com/festago/ticketing/repository/ReserveTicketRepository.java new file mode 100644 index 000000000..c67e630b2 --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/repository/ReserveTicketRepository.java @@ -0,0 +1,11 @@ +package com.festago.ticketing.repository; + +import com.festago.ticketing.domain.ReserveTicket; +import org.springframework.data.repository.Repository; + +public interface ReserveTicketRepository extends Repository { + + ReserveTicket save(ReserveTicket reserveTicket); + + long countByMemberIdAndTicketId(Long memberId, Long ticketId); +} diff --git a/backend/src/main/java/com/festago/ticketing/repository/TicketQuantityRepository.java b/backend/src/main/java/com/festago/ticketing/repository/TicketQuantityRepository.java new file mode 100644 index 000000000..15db977f4 --- /dev/null +++ b/backend/src/main/java/com/festago/ticketing/repository/TicketQuantityRepository.java @@ -0,0 +1,35 @@ +package com.festago.ticketing.repository; + +import com.festago.ticket.domain.NewTicket; +import com.festago.ticketing.domain.TicketQuantity; +import java.util.Optional; + +/** + * TicketQuantity를 저장, 삭제, 조회하는 Repository + */ +public interface TicketQuantityRepository { + + /** + * Ticket에 대한 정보를 가진 TicketQuantity를 만들어 저장한다.
+ * 기존 TicketQuantity가 저장되어 있으면 덮어쓴다.
+ * + * @param ticket 저장할 TicketQuantity에 대한 Ticket 엔티티 + * @return TicketQuantity + */ + TicketQuantity put(NewTicket ticket); + + /** + * Ticket의 식별자에 대한 TicketQuantity를 반환한다.
+ * + * @param ticketId Ticket의 식별자 + * @return TicketQuantity + */ + Optional findByTicketId(Long ticketId); + + /** + * Ticket에 대한 TicketQuantity를 삭제한다.
+ * + * @param ticket 삭제할 TicketQuantity에 대한 Ticket 엔티티 + */ + void delete(NewTicket ticket); +} diff --git a/backend/src/test/java/com/festago/common/infrastructure/FakeTicketingRateLimiter.java b/backend/src/test/java/com/festago/common/infrastructure/FakeTicketingRateLimiter.java new file mode 100644 index 000000000..1e42f3fa1 --- /dev/null +++ b/backend/src/test/java/com/festago/common/infrastructure/FakeTicketingRateLimiter.java @@ -0,0 +1,17 @@ +package com.festago.common.infrastructure; + +import com.festago.ticketing.domain.Booker; +import com.festago.ticketing.domain.TicketingRateLimiter; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class FakeTicketingRateLimiter implements TicketingRateLimiter { + + private final boolean isFrequentReserve; + + @Override + public boolean isFrequentTicketing(Booker booker, long timeout, TimeUnit unit) { + return isFrequentReserve; + } +} diff --git a/backend/src/test/java/com/festago/ticket/application/command/TicketMaxReserveCountChangeServiceTest.java b/backend/src/test/java/com/festago/ticket/application/command/TicketMaxReserveCountChangeServiceTest.java new file mode 100644 index 000000000..f61705f82 --- /dev/null +++ b/backend/src/test/java/com/festago/ticket/application/command/TicketMaxReserveCountChangeServiceTest.java @@ -0,0 +1,59 @@ +package com.festago.ticket.application.command; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.ticket.domain.FakeTicket; +import com.festago.ticket.domain.NewTicket; +import com.festago.ticket.repository.MemoryNewTicketRepository; +import com.festago.ticket.repository.NewTicketRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class TicketMaxReserveCountChangeServiceTest { + + TicketMaxReserveCountChangeService ticketMaxReserveCountChangeService; + + NewTicketRepository ticketRepository; + + @BeforeEach + void setUp() { + ticketRepository = new MemoryNewTicketRepository(); + ticketMaxReserveCountChangeService = new TicketMaxReserveCountChangeService( + ticketRepository + ); + } + + @Nested + class changeMaxReserveAmount { + + @Test + void 티켓이_존재하지_않으면_예외() { + // when & then + assertThatThrownBy(() -> ticketMaxReserveCountChangeService.changeMaxReserveAmount(4885L, 100)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.TICKET_NOT_FOUND.getMessage()); + } + + @Test + void 티켓이_존재하면_최대_예매_가능_개수가_변경된다() { + // given + NewTicket ticket = ticketRepository.save(new FakeTicket(1L, 100)); + + // when + ticketMaxReserveCountChangeService.changeMaxReserveAmount(ticket.getId(), 4885); + + // then + assertThat(ticketRepository.findById(ticket.getId())) + .map(NewTicket::getMaxReserveAmount) + .hasValue(4885); + } + } +} diff --git a/backend/src/test/java/com/festago/ticket/domain/FakeTicket.java b/backend/src/test/java/com/festago/ticket/domain/FakeTicket.java new file mode 100644 index 000000000..cd575f197 --- /dev/null +++ b/backend/src/test/java/com/festago/ticket/domain/FakeTicket.java @@ -0,0 +1,28 @@ +package com.festago.ticket.domain; + +import com.festago.ticketing.domain.Booker; +import com.festago.ticketing.domain.ReserveTicket; +import java.time.LocalDateTime; + +public class FakeTicket extends NewTicket { + + public FakeTicket(Long id, int quantity) { + super(id, 1L, TicketExclusive.NONE); + changeAmount(quantity); + } + + @Override + public void validateReserve(Booker booker, LocalDateTime currentTime) { + // NOOP + } + + @Override + public ReserveTicket reserve(Booker booker, int sequence) { + return null; // NOOP + } + + @Override + public LocalDateTime getTicketingEndTime() { + return LocalDateTime.now().plusHours(1); + } +} diff --git a/backend/src/test/java/com/festago/ticket/domain/validator/stage/ExistsTicketStageDeleteValidatorTest.java b/backend/src/test/java/com/festago/ticket/domain/validator/stage/ExistsTicketStageDeleteValidatorTest.java new file mode 100644 index 000000000..6966fb74a --- /dev/null +++ b/backend/src/test/java/com/festago/ticket/domain/validator/stage/ExistsTicketStageDeleteValidatorTest.java @@ -0,0 +1,63 @@ +package com.festago.ticket.domain.validator.stage; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.festival.domain.Festival; +import com.festago.school.domain.School; +import com.festago.stage.domain.Stage; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.SchoolFixture; +import com.festago.support.fixture.StageFixture; +import com.festago.support.fixture.StageTicketFixture; +import com.festago.ticket.repository.MemoryStageTicketRepository; +import com.festago.ticket.repository.StageTicketRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ExistsTicketStageDeleteValidatorTest { + + ExistsTicketStageDeleteValidator existsTicketStageDeleteValidator; + + StageTicketRepository stageTicketRepository; + + @BeforeEach + void setUp() { + stageTicketRepository = new MemoryStageTicketRepository(); + existsTicketStageDeleteValidator = new ExistsTicketStageDeleteValidator(stageTicketRepository); + } + + @Nested + class validate { + + @Test + void 공연에_티켓이_등록_되어있으면_예외() { + // given + School school = SchoolFixture.builder().id(1L).build(); + Festival festival = FestivalFixture.builder().school(school).build(); + Stage stage = StageFixture.builder().festival(festival).build(); + stageTicketRepository.save(StageTicketFixture.builder().schoolId(school.getId()).stage(stage).build()); + + // when & then + assertThatThrownBy(() -> existsTicketStageDeleteValidator.validate(stage)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.STAGE_DELETE_CONSTRAINT_EXISTS_TICKET.getMessage()); + } + + @Test + void 공연에_티켓이_없으면_예외가_발생하지_않는다() { + // given + Stage stage = StageFixture.builder().build(); + + // when & then + assertDoesNotThrow(() -> existsTicketStageDeleteValidator.validate(stage)); + } + } +} diff --git a/backend/src/test/java/com/festago/ticket/domain/validator/stage/ExistsTicketStageUpdateValidatorTest.java b/backend/src/test/java/com/festago/ticket/domain/validator/stage/ExistsTicketStageUpdateValidatorTest.java new file mode 100644 index 000000000..09110a829 --- /dev/null +++ b/backend/src/test/java/com/festago/ticket/domain/validator/stage/ExistsTicketStageUpdateValidatorTest.java @@ -0,0 +1,63 @@ +package com.festago.ticket.domain.validator.stage; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.festival.domain.Festival; +import com.festago.school.domain.School; +import com.festago.stage.domain.Stage; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.SchoolFixture; +import com.festago.support.fixture.StageFixture; +import com.festago.support.fixture.StageTicketFixture; +import com.festago.ticket.repository.MemoryStageTicketRepository; +import com.festago.ticket.repository.StageTicketRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class ExistsTicketStageUpdateValidatorTest { + + ExistsTicketStageUpdateValidator existsTicketStageUpdateValidator; + + StageTicketRepository stageTicketRepository; + + @BeforeEach + void setUp() { + stageTicketRepository = new MemoryStageTicketRepository(); + existsTicketStageUpdateValidator = new ExistsTicketStageUpdateValidator(stageTicketRepository); + } + + @Nested + class validate { + + @Test + void 공연에_티켓이_등록_되어있으면_예외() { + // given + School school = SchoolFixture.builder().id(1L).build(); + Festival festival = FestivalFixture.builder().school(school).build(); + Stage stage = StageFixture.builder().festival(festival).build(); + stageTicketRepository.save(StageTicketFixture.builder().schoolId(school.getId()).stage(stage).build()); + + // when & then + assertThatThrownBy(() -> existsTicketStageUpdateValidator.validate(stage)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.STAGE_UPDATE_CONSTRAINT_EXISTS_TICKET.getMessage()); + } + + @Test + void 공연에_티켓이_없으면_예외가_발생하지_않는다() { + // given + Stage stage = StageFixture.builder().build(); + + // when & then + assertDoesNotThrow(() -> existsTicketStageUpdateValidator.validate(stage)); + } + } +} diff --git a/backend/src/test/java/com/festago/ticket/repository/MemoryNewTicketRepository.java b/backend/src/test/java/com/festago/ticket/repository/MemoryNewTicketRepository.java new file mode 100644 index 000000000..1e0f07cc0 --- /dev/null +++ b/backend/src/test/java/com/festago/ticket/repository/MemoryNewTicketRepository.java @@ -0,0 +1,8 @@ +package com.festago.ticket.repository; + +import com.festago.support.AbstractMemoryRepository; +import com.festago.ticket.domain.NewTicket; + +public class MemoryNewTicketRepository extends AbstractMemoryRepository implements NewTicketRepository { + +} diff --git a/backend/src/test/java/com/festago/ticketing/application/command/QuantityTicketingServiceTest.java b/backend/src/test/java/com/festago/ticketing/application/command/QuantityTicketingServiceTest.java new file mode 100644 index 000000000..93e11a6a9 --- /dev/null +++ b/backend/src/test/java/com/festago/ticketing/application/command/QuantityTicketingServiceTest.java @@ -0,0 +1,101 @@ +package com.festago.ticketing.application.command; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festago.common.infrastructure.FakeTicketingRateLimiter; +import com.festago.ticket.domain.FakeTicket; +import com.festago.ticketing.domain.TicketQuantity; +import com.festago.ticketing.dto.command.TicketingCommand; +import com.festago.ticketing.repository.MemoryTicketQuantityRepository; +import com.festago.ticketing.repository.TicketQuantityRepository; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.LongStream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class QuantityTicketingServiceTest { + + QuantityTicketingService quantityTicketingService; + + TicketQuantityRepository ticketQuantityRepository; + + ExecutorService executorService = Executors.newFixedThreadPool(8); + + FakeTicketingRateLimiter fakeMemberRateLimiter = new FakeTicketingRateLimiter(false); + + Long ticketId = 1L; + + @BeforeEach + void setUp() { + ticketQuantityRepository = new MemoryTicketQuantityRepository(); + } + + @Test + void 티켓팅은_동시성_문제가_발생하지_않아야_한다() { + // given + int ticketAmount = 50; + long tryCount = 100; + TicketQuantity ticketQuantity = ticketQuantityRepository.put(new FakeTicket(1L, ticketAmount)); + AtomicLong reserveCount = new AtomicLong(); + quantityTicketingService = new QuantityTicketingService(ticketQuantityRepository, command -> { + reserveCount.incrementAndGet(); + return null; + }, fakeMemberRateLimiter); + var command = TicketingCommand.builder() + .ticketId(ticketId) + .build(); + + // when + List> futures = LongStream.rangeClosed(1, tryCount) + .mapToObj(i -> CompletableFuture.runAsync(() -> { + quantityTicketingService.ticketing(command); + }, executorService).exceptionally(throwable -> null)) + .toList(); + futures.forEach(CompletableFuture::join); + + // then + assertThat(ticketQuantity.getQuantity()).isZero(); + assertThat(reserveCount).hasValue(ticketAmount); + } + + @Test + void 티켓팅_도중_예외가_발생하면_재고가_복구되고_동시성_문제가_발생하지_않아야_한다() { + // given + int ticketAmount = 50; + long tryCount = 100; + TicketQuantity ticketQuantity = ticketQuantityRepository.put(new FakeTicket(1L, ticketAmount)); + AtomicLong reserveCount = new AtomicLong(); + AtomicLong counter = new AtomicLong(); + quantityTicketingService = new QuantityTicketingService(ticketQuantityRepository, command -> { + long count = counter.incrementAndGet(); + if (count <= 25 && count % 5 == 0) { // 5번 예외 발생 + throw new IllegalArgumentException(); + } + reserveCount.incrementAndGet(); + return null; + }, fakeMemberRateLimiter); + var command = TicketingCommand.builder() + .ticketId(ticketId) + .build(); + + // when + List> futures = LongStream.rangeClosed(1, tryCount) + .mapToObj(i -> CompletableFuture.runAsync(() -> { + quantityTicketingService.ticketing(command); + }, executorService).exceptionally(throwable -> null)) + .toList(); + futures.forEach(CompletableFuture::join); + + // then + assertThat(ticketQuantity.getQuantity()).isZero(); + assertThat(reserveCount).hasValue(ticketAmount); + } +} diff --git a/backend/src/test/java/com/festago/ticketing/application/command/TicketingCommandServiceTest.java b/backend/src/test/java/com/festago/ticketing/application/command/TicketingCommandServiceTest.java new file mode 100644 index 000000000..ddd22d0bd --- /dev/null +++ b/backend/src/test/java/com/festago/ticketing/application/command/TicketingCommandServiceTest.java @@ -0,0 +1,142 @@ +package com.festago.ticketing.application.command; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.spy; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.common.exception.NotFoundException; +import com.festago.festival.domain.Festival; +import com.festago.school.domain.School; +import com.festago.stage.domain.Stage; +import com.festago.support.TimeInstantProvider; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.SchoolFixture; +import com.festago.support.fixture.StageFixture; +import com.festago.support.fixture.StageTicketFixture; +import com.festago.ticket.domain.NewTicketType; +import com.festago.ticket.domain.StageTicket; +import com.festago.ticket.repository.MemoryStageTicketRepository; +import com.festago.ticket.repository.NewTicketDao; +import com.festago.ticket.repository.StageTicketRepository; +import com.festago.ticketing.domain.Booker; +import com.festago.ticketing.dto.command.TicketingCommand; +import com.festago.ticketing.infrastructure.MemoryTicketingSequenceGenerator; +import com.festago.ticketing.repository.MemoryReserveTicketRepository; +import com.festago.ticketing.repository.ReserveTicketRepository; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Collections; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class TicketingCommandServiceTest { + + TicketingCommandServiceImpl ticketingCommandService; + + ReserveTicketRepository reserveTicketRepository; + + StageTicketRepository stageTicketRepository; + + Clock clock; + + @BeforeEach + void setUp() { + reserveTicketRepository = new MemoryReserveTicketRepository(); + stageTicketRepository = new MemoryStageTicketRepository(); + clock = spy(Clock.systemDefaultZone()); + ticketingCommandService = new TicketingCommandServiceImpl( + new NewTicketDao(stageTicketRepository), + reserveTicketRepository, + new MemoryTicketingSequenceGenerator(), + Collections.emptyList(), + clock + ); + } + + @Nested + class reserveTicket { + + StageTicket stageTicket; + LocalDateTime 무대_시작_시간 = LocalDateTime.parse("2077-06-30T18:00:00"); + LocalDateTime 티켓_오픈_시간 = LocalDateTime.parse("2077-06-23T18:00:00"); + + @BeforeEach + void setUp() { + School school = SchoolFixture.builder().id(1L).build(); + Festival festival = FestivalFixture.builder() + .school(school) + .startDate(무대_시작_시간.toLocalDate()) + .endDate(무대_시작_시간.toLocalDate()) + .build(); + Stage stage = StageFixture.builder() + .festival(festival) + .startTime(무대_시작_시간) + .ticketOpenTime(티켓_오픈_시간) + .build(); + stageTicket = stageTicketRepository.save(StageTicketFixture.builder() + .schoolId(school.getId()) + .stage(stage) + .build()); + stageTicket.addTicketEntryTime(school.getId(), 티켓_오픈_시간.minusHours(1), 무대_시작_시간.minusHours(1), 100); + LocalDateTime now = LocalDateTime.parse("2077-06-24T18:00:00"); + given(clock.instant()) + .willReturn(TimeInstantProvider.from(now)); + } + + @Test + void 티켓의_식별자에_해당하는_티켓이_없으면_예외() { + // given + var command = TicketingCommand.builder() + .ticketId(4885L) + .booker(new Booker(1L, 1L)) + .ticketType(NewTicketType.STAGE) + .build(); + + // when & then + assertThatThrownBy(() -> ticketingCommandService.reserveTicket(command)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorCode.TICKET_NOT_FOUND.getMessage()); + } + + @Test + void 예매할_수_있는_티켓의_개수를_초과하면_예외() { + // given + var command = TicketingCommand.builder() + .ticketId(stageTicket.getId()) + .booker(new Booker(1L, 1L)) + .ticketType(NewTicketType.STAGE) + .build(); + ticketingCommandService.reserveTicket(command); + + // when & then + assertThatThrownBy(() -> ticketingCommandService.reserveTicket(command)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.RESERVE_TICKET_OVER_AMOUNT.getMessage()); + } + + @Test + void 티켓_예매에_성공하면_예매한_티켓이_영속된다() { + // given + var command = TicketingCommand.builder() + .ticketId(stageTicket.getId()) + .booker(new Booker(1L, 1L)) + .ticketType(NewTicketType.STAGE) + .build(); + + // when + ticketingCommandService.reserveTicket(command); + + // then + assertThat(reserveTicketRepository.countByMemberIdAndTicketId(1L, stageTicket.getId())) + .isEqualTo(1); + } + } +} diff --git a/backend/src/test/java/com/festago/ticketing/domain/validator/StageSingleTicketTypeTicketingValidatorTest.java b/backend/src/test/java/com/festago/ticketing/domain/validator/StageSingleTicketTypeTicketingValidatorTest.java new file mode 100644 index 000000000..70e759e32 --- /dev/null +++ b/backend/src/test/java/com/festago/ticketing/domain/validator/StageSingleTicketTypeTicketingValidatorTest.java @@ -0,0 +1,74 @@ +package com.festago.ticketing.domain.validator; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.festival.domain.Festival; +import com.festago.school.domain.School; +import com.festago.stage.domain.Stage; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.SchoolFixture; +import com.festago.support.fixture.StageFixture; +import com.festago.support.fixture.StageTicketFixture; +import com.festago.ticket.domain.FakeTicket; +import com.festago.ticket.domain.StageTicket; +import com.festago.ticketing.domain.Booker; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class StageSingleTicketTypeTicketingValidatorTest { + + StageSingleTicketTypeTicketingValidator validator; + + Long ticketId = 1L; + Long otherTicketId = 2L; + Long schoolId = 1L; + Long memberId = 1L; + + @Test + void 티켓의_식별자가_다르면_예외() { + // given + StageTicket ticket = createStageTicket(ticketId, schoolId); + Booker booker = new Booker(memberId, schoolId); + validator = new StageSingleTicketTypeTicketingValidator((memberId, stageId) -> otherTicketId); + + // when & then + assertThatThrownBy(() -> validator.validate(ticket, booker)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorCode.ONLY_STAGE_TICKETING_SINGLE_TYPE.getMessage()); + } + + @Test + void 티켓의_식별자와_같으면_예외가_발생하지_않는다() { + // given + StageTicket ticket = createStageTicket(ticketId, schoolId); + Booker booker = new Booker(memberId, schoolId); + validator = new StageSingleTicketTypeTicketingValidator((memberId, stageId) -> ticketId); + + // when & then + assertDoesNotThrow(() -> validator.validate(ticket, booker)); + } + + @Test + void 티켓의_타입이_StageTicket이_아니라면_티켓의_식별자가_달라도_예외가_발생하지_않는다() { + // given + FakeTicket ticket = new FakeTicket(ticketId, 100); + Booker booker = new Booker(memberId, schoolId); + validator = new StageSingleTicketTypeTicketingValidator((memberId, stageId) -> otherTicketId); + + // when & then + assertDoesNotThrow(() -> validator.validate(ticket, booker)); + } + + private StageTicket createStageTicket(Long ticketId, Long schoolId) { + School school = SchoolFixture.builder().id(schoolId).build(); + Festival festival = FestivalFixture.builder().school(school).build(); + Stage stage = StageFixture.builder().festival(festival).build(); + return StageTicketFixture.builder().id(ticketId).stage(stage).schoolId(schoolId).build(); + } +} diff --git a/backend/src/test/java/com/festago/ticketing/infrastructure/MemoryTicketQuantity.java b/backend/src/test/java/com/festago/ticketing/infrastructure/MemoryTicketQuantity.java new file mode 100644 index 000000000..3d5e84db5 --- /dev/null +++ b/backend/src/test/java/com/festago/ticketing/infrastructure/MemoryTicketQuantity.java @@ -0,0 +1,38 @@ +package com.festago.ticketing.infrastructure; + +import com.festago.common.exception.BadRequestException; +import com.festago.common.exception.ErrorCode; +import com.festago.ticketing.domain.TicketQuantity; +import java.util.concurrent.atomic.AtomicInteger; + +public class MemoryTicketQuantity implements TicketQuantity { + + private final AtomicInteger quantity; + + public MemoryTicketQuantity(int quantity) { + this.quantity = new AtomicInteger(quantity); + } + + @Override + public boolean isSoldOut() { + return quantity.get() <= 0; + } + + @Override + public void decreaseQuantity() { + int remainAmount = quantity.decrementAndGet(); + if (remainAmount < 0) { // 티켓 재고 음수 방지 + throw new BadRequestException(ErrorCode.TICKET_SOLD_OUT); + } + } + + @Override + public void increaseQuantity() { + quantity.incrementAndGet(); + } + + @Override + public int getQuantity() { + return quantity.get(); + } +} diff --git a/backend/src/test/java/com/festago/ticketing/infrastructure/MemoryTicketingSequenceGenerator.java b/backend/src/test/java/com/festago/ticketing/infrastructure/MemoryTicketingSequenceGenerator.java new file mode 100644 index 000000000..1bb91284f --- /dev/null +++ b/backend/src/test/java/com/festago/ticketing/infrastructure/MemoryTicketingSequenceGenerator.java @@ -0,0 +1,17 @@ +package com.festago.ticketing.infrastructure; + +import com.festago.ticketing.domain.TicketingSequenceGenerator; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +public class MemoryTicketingSequenceGenerator implements TicketingSequenceGenerator { + + private final Map memory = new ConcurrentHashMap<>(); + + @Override + public int generate(Long ticketId) { + AtomicInteger sequence = memory.computeIfAbsent(ticketId, ignore -> new AtomicInteger()); + return sequence.incrementAndGet(); + } +} diff --git a/backend/src/test/java/com/festago/ticketing/infrastructure/validator/StageReservedTicketIdResolverImplTest.java b/backend/src/test/java/com/festago/ticketing/infrastructure/validator/StageReservedTicketIdResolverImplTest.java new file mode 100644 index 000000000..da110e946 --- /dev/null +++ b/backend/src/test/java/com/festago/ticketing/infrastructure/validator/StageReservedTicketIdResolverImplTest.java @@ -0,0 +1,88 @@ +package com.festago.ticketing.infrastructure.validator; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.festago.festival.domain.Festival; +import com.festago.festival.repository.FestivalRepository; +import com.festago.school.domain.School; +import com.festago.school.repository.SchoolRepository; +import com.festago.stage.domain.Stage; +import com.festago.stage.repository.StageRepository; +import com.festago.support.ApplicationIntegrationTest; +import com.festago.support.fixture.FestivalFixture; +import com.festago.support.fixture.SchoolFixture; +import com.festago.support.fixture.StageFixture; +import com.festago.support.fixture.StageTicketFixture; +import com.festago.ticket.domain.StageTicket; +import com.festago.ticket.repository.StageTicketRepository; +import com.festago.ticketing.domain.Booker; +import com.festago.ticketing.repository.ReserveTicketRepository; +import java.time.LocalDateTime; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +@DisplayNameGeneration(DisplayNameGenerator.ReplaceUnderscores.class) +@SuppressWarnings("NonAsciiCharacters") +class StageReservedTicketIdResolverImplTest extends ApplicationIntegrationTest { + + @Autowired + StageReservedTicketIdResolverImpl stageReservedTicketIdResolver; + + @Autowired + ReserveTicketRepository reserveTicketRepository; + + @Autowired + StageTicketRepository stageTicketRepository; + + @Autowired + SchoolRepository schoolRepository; + + @Autowired + FestivalRepository festivalRepository; + + @Autowired + StageRepository stageRepository; + + School school; + + Stage stage; + + StageTicket stageTicket; + + @BeforeEach + void setUp() { + school = schoolRepository.save(SchoolFixture.builder().build()); + Festival festival = festivalRepository.save(FestivalFixture.builder().school(school).build()); + stage = stageRepository.save(StageFixture.builder().festival(festival).build()); + stageTicket = stageTicketRepository.save( + StageTicketFixture.builder().schoolId(school.getId()).stage(stage).build()); + LocalDateTime now = stage.getTicketOpenTime().minusHours(1); + LocalDateTime entryTime = stage.getStartTime().minusHours(1); + stageTicket.addTicketEntryTime(school.getId(), now, entryTime, 100); + } + + @Test + void 사용자가_예매한_티켓이_없으면_null이_반환된다() { + // when + Long ticketId = stageReservedTicketIdResolver.resolve(1L, stage.getId()); + + // then + assertThat(ticketId).isNull(); + } + + @Test + void 사용자가_공연의_티켓에_예매한_티켓이_있으면_해당_티켓의_식별자가_반환된다() { + // given + Booker booker = new Booker(1L, school.getId()); + reserveTicketRepository.save(stageTicket.reserve(booker, 1)); + + // when + Long ticketId = stageReservedTicketIdResolver.resolve(1L, stage.getId()); + + // then + assertThat(ticketId).isEqualTo(stageTicket.getId()); + } +} diff --git a/backend/src/test/java/com/festago/ticketing/repository/MemoryReserveTicketRepository.java b/backend/src/test/java/com/festago/ticketing/repository/MemoryReserveTicketRepository.java new file mode 100644 index 000000000..0967e6981 --- /dev/null +++ b/backend/src/test/java/com/festago/ticketing/repository/MemoryReserveTicketRepository.java @@ -0,0 +1,17 @@ +package com.festago.ticketing.repository; + +import com.festago.support.AbstractMemoryRepository; +import com.festago.ticketing.domain.ReserveTicket; +import java.util.Objects; + +public class MemoryReserveTicketRepository extends AbstractMemoryRepository implements + ReserveTicketRepository { + + @Override + public long countByMemberIdAndTicketId(Long memberId, Long ticketId) { + return memory.values().stream() + .filter(it -> Objects.equals(it.getMemberId(), memberId)) + .filter(it -> Objects.equals(it.getTicketId(), ticketId)) + .count(); + } +} diff --git a/backend/src/test/java/com/festago/ticketing/repository/MemoryTicketQuantityRepository.java b/backend/src/test/java/com/festago/ticketing/repository/MemoryTicketQuantityRepository.java new file mode 100644 index 000000000..0ff7cb127 --- /dev/null +++ b/backend/src/test/java/com/festago/ticketing/repository/MemoryTicketQuantityRepository.java @@ -0,0 +1,29 @@ +package com.festago.ticketing.repository; + +import com.festago.ticket.domain.NewTicket; +import com.festago.ticketing.domain.TicketQuantity; +import com.festago.ticketing.infrastructure.MemoryTicketQuantity; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +public class MemoryTicketQuantityRepository implements TicketQuantityRepository { + + private final ConcurrentHashMap memory = new ConcurrentHashMap<>(); + + @Override + public TicketQuantity put(NewTicket ticket) { + TicketQuantity ticketQuantity = new MemoryTicketQuantity(ticket.getAmount()); + memory.put(ticket.getId(), ticketQuantity); + return ticketQuantity; + } + + @Override + public Optional findByTicketId(Long ticketId) { + return Optional.ofNullable(memory.get(ticketId)); + } + + @Override + public void delete(NewTicket ticket) { + memory.remove(ticket.getId()); + } +}