Skip to content

Commit

Permalink
feat: 티켓팅 로직 추가
Browse files Browse the repository at this point in the history
  • Loading branch information
seokjin8678 committed Jun 11, 2024
1 parent 29f42c5 commit c5ec08d
Show file tree
Hide file tree
Showing 40 changed files with 1,429 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<? extends NewTicket> ticket = switch (ticketType) {
case STAGE -> stageTicketRepository.findByIdWithFetch(id);
};
return ticket.orElseThrow(() -> new NotFoundException(ErrorCode.TICKET_NOT_FOUND));
}
}
Original file line number Diff line number Diff line change
@@ -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, Long> {

NewTicket save(NewTicket ticket);

Optional<NewTicket> findById(Long id);
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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<TicketingValidator> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.festago.ticketing.domain;

import com.festago.common.exception.BadRequestException;

/**
* 티켓의 재고를 관리하는 도메인 <br/> 해당 도메인을 구현하는 구현체는 반드시 원자적인 연산을 사용해야 한다. <br/>
*/
public interface TicketQuantity {

/**
* 티켓의 매진 여부를 반환한다.
*
* @return 티켓이 매진이면 true, 매진이 아니면 false
*/
boolean isSoldOut();

/**
* 티켓의 재고를 하나 감소시킨다. <br/> 해당 메서드의 연산은 atomic 해야 한다. <br/> 감소 시킨 뒤 값이 음수인 경우에는 매진이 된 상태에 요청이 들어온 것이므로, 예외를 던져야 한다.
* <br/>
*
* @throws BadRequestException 감소 시킨 뒤 값이 음수이면
*/
void decreaseQuantity() throws BadRequestException;

/**
* 티켓의 재고를 하나 증가시킨다. <br/> 해당 메서드의 연산은 atomic 해야 한다. <br/>
*/
void increaseQuantity();

/**
* 티켓의 남은 재고를 반환한다. <br/>
*
* @return 티켓의 남은 재고
*/
int getQuantity();
}
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.festago.ticketing.domain;

public interface TicketingSequenceGenerator {

int generate(Long ticketId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.festago.ticketing.domain.validator;

import jakarta.annotation.Nullable;

public interface StageReservedTicketIdResolver {

/**
* 사용자가 StageTicket에 대해 예매한 티켓의 식별자를 반환합니다. <br/> 예매한 이력이 없으면 null이 반환되고, 예매한 이력이 있으면 사용자가 예매했던 티켓의 식별자를 반환합니다.
* <br/>
*
* @param memberId 티켓을 예매한 사용자의 식별자
* @param stageId StageTicket의 Stage 식별자
* @return 예매한 이력이 없으면 null, 예매한 이력이 있으면 티켓 식별자 반환
*/
@Nullable
Long resolve(Long memberId, Long stageId);
}
Original file line number Diff line number Diff line change
@@ -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;

/**
* 공연의 티켓에 대해 하나의 유형에 대해서만 예매가 가능하도록 검증하는 클래스 <br/> ex) 사용자가 하나의 공연에 대해 학생 전용, 외부인 전용을 모두 예매하는 상황을 방지 <br/>
*/
@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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
Loading

0 comments on commit c5ec08d

Please sign in to comment.