diff --git a/module-application/build.gradle b/module-application/build.gradle index 735319496..393efc34d 100644 --- a/module-application/build.gradle +++ b/module-application/build.gradle @@ -22,6 +22,7 @@ dependencies { implementation project(':module-domain') implementation project(':module-common') implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-validation' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaCreateCommandParam.java b/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaCreateCommandParam.java index 7bc856aa0..69985bc2b 100644 --- a/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaCreateCommandParam.java +++ b/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaCreateCommandParam.java @@ -1,14 +1,23 @@ package project.redis.application.cinema.port.inbound; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@Builder -@AllArgsConstructor -@NoArgsConstructor -public class CinemaCreateCommandParam { - private String CinemaName; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Value; +import project.redis.common.SelfValidating; + +@Getter +@Value +@EqualsAndHashCode(callSuper = false) +public class CinemaCreateCommandParam extends SelfValidating { + + @NotNull(message = "COMMON.ERROR.NOT_NULL") + @NotBlank(message = "COMMON.ERROR.NOT_BLANK") + String cinemaName; + + public CinemaCreateCommandParam(String cinemaName) { + this.cinemaName = cinemaName; + this.validate(); + } } diff --git a/module-application/src/main/java/project/redis/application/reservation/port/inbound/ReservationCommandUseCase.java b/module-application/src/main/java/project/redis/application/reservation/port/inbound/ReservationCommandUseCase.java new file mode 100644 index 000000000..a4f923030 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/reservation/port/inbound/ReservationCommandUseCase.java @@ -0,0 +1,6 @@ +package project.redis.application.reservation.port.inbound; + +public interface ReservationCommandUseCase { + + boolean reserve(ReserveCommandParam param); +} diff --git a/module-application/src/main/java/project/redis/application/reservation/port/inbound/ReserveCommandParam.java b/module-application/src/main/java/project/redis/application/reservation/port/inbound/ReserveCommandParam.java new file mode 100644 index 000000000..c0c0785c7 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/reservation/port/inbound/ReserveCommandParam.java @@ -0,0 +1,33 @@ +package project.redis.application.reservation.port.inbound; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import java.util.UUID; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Value; +import project.redis.common.SelfValidating; + +@Getter +@Value +@EqualsAndHashCode(callSuper = false) +public class ReserveCommandParam extends SelfValidating { + + @NotNull + @Size(min = 1, max = 5) + List seatIds; + + @NotNull + UUID screeningId; + + @NotNull + String userName; + + public ReserveCommandParam(List seatIds, UUID screeningId, String userName) { + this.seatIds = seatIds; + this.screeningId = screeningId; + this.userName = userName; + validate(); + } +} diff --git a/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationCommandPort.java b/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationCommandPort.java new file mode 100644 index 000000000..8424db0ef --- /dev/null +++ b/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationCommandPort.java @@ -0,0 +1,7 @@ +package project.redis.application.reservation.port.outbound; + +import project.redis.domain.reservation.Reservation; + +public interface ReservationCommandPort { + void reserve(Reservation reservation); +} diff --git a/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationLockPort.java b/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationLockPort.java new file mode 100644 index 000000000..3c223ceb4 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationLockPort.java @@ -0,0 +1,13 @@ +package project.redis.application.reservation.port.outbound; + +import java.util.List; + +public interface ReservationLockPort { + boolean tryLock(String lockKey, long waitTimeMils, long releaseTimeMils); + + boolean tryScreeningSeatLock(List lockKeys, long waitTimeMils, long releaseTimeMils); + + void releaseLock(String lockKey); + + void releaseMultiLock(List lockKeys); +} diff --git a/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationQueryPort.java b/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationQueryPort.java new file mode 100644 index 000000000..6e2f67729 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationQueryPort.java @@ -0,0 +1,10 @@ +package project.redis.application.reservation.port.outbound; + +import java.util.List; +import java.util.UUID; +import project.redis.domain.reservation.Reservation; + +public interface ReservationQueryPort { + + List getReservations(UUID screeningId); +} diff --git a/module-application/src/main/java/project/redis/application/reservation/service/ReservationCommandService.java b/module-application/src/main/java/project/redis/application/reservation/service/ReservationCommandService.java new file mode 100644 index 000000000..dcc3fd99e --- /dev/null +++ b/module-application/src/main/java/project/redis/application/reservation/service/ReservationCommandService.java @@ -0,0 +1,111 @@ +package project.redis.application.reservation.service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; +import project.redis.application.reservation.port.inbound.ReservationCommandUseCase; +import project.redis.application.reservation.port.inbound.ReserveCommandParam; +import project.redis.application.reservation.port.outbound.ReservationCommandPort; +import project.redis.application.reservation.port.outbound.ReservationLockPort; +import project.redis.application.reservation.port.outbound.ReservationQueryPort; +import project.redis.application.screening.port.outbound.ScreeningQueryPort; +import project.redis.application.seat.port.outbound.SeatQueryPort; +import project.redis.common.exception.DataInvalidException; +import project.redis.common.exception.ErrorCode; +import project.redis.domain.reservation.Reservation; +import project.redis.domain.screening.Screening; +import project.redis.domain.seat.Seat; + + +@Slf4j +@Service +@RequiredArgsConstructor +public class ReservationCommandService implements ReservationCommandUseCase { + + private final SeatQueryPort seatQueryPort; + private final ReservationQueryPort reservationQueryPort; + private final ScreeningQueryPort screeningQueryPort; + private final ReservationCommandPort reservationCommandPort; + + private final ReservationLockPort reservationLockPort; + + /* + 적용 비지니스 규칙 + 1. 들어온 좌석은 연속된 좌석이어야 한다. + 2. 이미 예약이 존재하는 좌석은 예약이 불가능하다. + 3. 사용자의 예약은 모두 연속된 좌석이어야 한다. + 4. 사용자는 최대 해당 상영관에 대해서 5개까지 예약이 가능하다. + */ + @Override + public boolean reserve(ReserveCommandParam param) { + List seatIds = param.getSeatIds().stream().map(String::valueOf).toList(); + boolean lock = reservationLockPort.tryScreeningSeatLock( + makeLockKey(param.getScreeningId().toString(), seatIds), + 20, + 1000); + + if (!lock) { + log.info("locking screening for seat {} failed", param.getScreeningId()); + throw new DataInvalidException(ErrorCode.SEAT_ALREADY_RESERVED, param.getSeatIds().toString()); + } + + try { + // 연속된 좌석인지 여부 + List seats = seatQueryPort.getSeats(param.getSeatIds()); + if (!Seat.isSeries(seats)) { + throw new DataInvalidException(ErrorCode.SEAT_REQUIRED_SERIES); + } + + // 예약 가져오기 + List originReservations = reservationQueryPort.getReservations(param.getScreeningId()); + + List seatList = originReservations.stream() + .flatMap(reservation -> reservation.getSeats().stream()) + .map(Seat::getSeatId) + .collect(Collectors.toList()); + + // 이미 예약이 존재하는 좌석인지 검증 + boolean isAlreadyReservation = seatList.retainAll(param.getSeatIds()); + + if( isAlreadyReservation ) { + throw new DataInvalidException(ErrorCode.SEAT_ALREADY_RESERVED, seatList.toString()); + } + + List originSeats = originReservations.stream() + .filter(reservation -> reservation.getUsername().equals(param.getUserName())) + .flatMap(reservation -> reservation.getSeats().stream()) + .toList(); + + // 이전 예약 + 현재 예약하려는 좌석의 연속성 검증 && 5개 이하의 예약 검증 + Seat.isAvailable(originSeats, seats); + + Screening screening = !CollectionUtils.isEmpty(originReservations) + ? originReservations.getFirst().getScreening() + : screeningQueryPort.getScreening(param.getScreeningId()); + + if (!screening.isLaterScreening()) { + throw new DataInvalidException(ErrorCode.SCREENING_REQUIRED_LATER_NOW, param.getScreeningId()); + } + + Reservation reservation + = Reservation.generateReservation( + null, LocalDateTime.now(), param.getUserName(), screening, seats); + + reservationCommandPort.reserve(reservation); + return true; + } finally { + reservationLockPort.releaseMultiLock(makeLockKey(param.getScreeningId().toString(), seatIds)); + } + } + + private List makeLockKey(String screeningId, List list) { + return list.stream() + .map(seatId -> "reservation-lock:" + screeningId + ":" + seatId) + .toList(); + } +} \ No newline at end of file diff --git a/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryPort.java b/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryPort.java index 9eba9a723..442e93614 100644 --- a/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryPort.java +++ b/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryPort.java @@ -1,6 +1,7 @@ package project.redis.application.screening.port.outbound; import java.util.List; +import java.util.UUID; import project.redis.domain.screening.Screening; public interface ScreeningQueryPort { @@ -10,4 +11,6 @@ public interface ScreeningQueryPort { List getScreeningsRedis(ScreeningQueryFilter filter); List getScreeningsLocalCache(ScreeningQueryFilter filter); + + Screening getScreening(UUID screeningId); } diff --git a/module-application/src/main/java/project/redis/application/seat/port/outbound/SeatQueryPort.java b/module-application/src/main/java/project/redis/application/seat/port/outbound/SeatQueryPort.java new file mode 100644 index 000000000..f93e1101d --- /dev/null +++ b/module-application/src/main/java/project/redis/application/seat/port/outbound/SeatQueryPort.java @@ -0,0 +1,10 @@ +package project.redis.application.seat.port.outbound; + +import java.util.List; +import java.util.UUID; +import project.redis.domain.seat.Seat; + +public interface SeatQueryPort { + + List getSeats(List seatIds); +} diff --git a/module-application/src/test/java/project/redis/application/cinema/service/CinemaCommandServiceTest.java b/module-application/src/test/java/project/redis/application/cinema/service/CinemaCommandServiceTest.java index 4ca80db2a..55249051e 100644 --- a/module-application/src/test/java/project/redis/application/cinema/service/CinemaCommandServiceTest.java +++ b/module-application/src/test/java/project/redis/application/cinema/service/CinemaCommandServiceTest.java @@ -29,9 +29,7 @@ class CinemaCommandServiceTest { @Test void testCreateCinema() { String cinemaName = "cinema"; - CinemaCreateCommandParam param = CinemaCreateCommandParam.builder() - .CinemaName(cinemaName) - .build(); + CinemaCreateCommandParam param = new CinemaCreateCommandParam(cinemaName); doNothing().when(cinemaCommandPort).createCinema(param.getCinemaName()); @@ -44,9 +42,7 @@ void testCreateCinema() { @Test void testCreateCinemaWithInvalidCinemaName() { String cinemaName = "cinema"; - CinemaCreateCommandParam param = CinemaCreateCommandParam.builder() - .CinemaName(cinemaName) - .build(); + CinemaCreateCommandParam param = new CinemaCreateCommandParam(cinemaName); doThrow(IllegalArgumentException.class) .when(cinemaCommandPort).createCinema(param.getCinemaName()); diff --git a/module-application/src/test/java/project/redis/application/reservation/service/ReservationCommandServiceTest.java b/module-application/src/test/java/project/redis/application/reservation/service/ReservationCommandServiceTest.java new file mode 100644 index 000000000..e3bd348bb --- /dev/null +++ b/module-application/src/test/java/project/redis/application/reservation/service/ReservationCommandServiceTest.java @@ -0,0 +1,143 @@ +package project.redis.application.reservation.service; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +import jakarta.validation.ConstraintViolationException; +import java.util.List; +import java.util.UUID; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import project.redis.application.reservation.port.inbound.ReserveCommandParam; +import project.redis.application.reservation.port.outbound.ReservationQueryPort; +import project.redis.application.seat.port.outbound.SeatQueryPort; +import project.redis.common.exception.DataInvalidException; +import project.redis.domain.cinema.Cinema; +import project.redis.domain.reservation.Reservation; +import project.redis.domain.seat.Seat; + +@ExtendWith(MockitoExtension.class) +class ReservationCommandServiceTest { + + @Mock + SeatQueryPort seatQueryPort; + + @Mock + ReservationQueryPort reservationQueryPort; + + @InjectMocks + ReservationCommandService reservationCommandService; + + @Test + void testSeriesSeatNoSeriesSeat() { + // Arrange + UUID cinemaId = UUID.randomUUID(); + Cinema cinema = Cinema.generateCinema(cinemaId, "Test Cinema"); + + Seat seat1 = Seat.generateSeat(UUID.randomUUID(), "A1", cinema); + Seat seat2 = Seat.generateSeat(UUID.randomUUID(), "A2", cinema); + Seat seat3 = Seat.generateSeat(UUID.randomUUID(), "A4", cinema); + + List seats = List.of(seat1, seat2, seat3); + + List seatIds = seats.stream().map(Seat::getSeatId).toList(); + ReserveCommandParam param = new ReserveCommandParam(seatIds, + UUID.randomUUID(), "user"); + + when(seatQueryPort.getSeats(seatIds)).thenReturn(seats); + + Assertions.assertThatThrownBy(() -> reservationCommandService.reserve(param)); + + + } + + @Test + void testSeriesSeatInputModelNoValidate() { + // Arrange + UUID cinemaId = UUID.randomUUID(); + Cinema cinema = Cinema.generateCinema(cinemaId, "Test Cinema"); + + Seat seat1 = Seat.generateSeat(UUID.randomUUID(), "A1", cinema); + Seat seat2 = Seat.generateSeat(UUID.randomUUID(), "A2", cinema); + Seat seat3 = Seat.generateSeat(UUID.randomUUID(), "A4", cinema); + Seat seat4 = Seat.generateSeat(UUID.randomUUID(), "A4", cinema); + Seat seat5 = Seat.generateSeat(UUID.randomUUID(), "A4", cinema); + Seat seat6 = Seat.generateSeat(UUID.randomUUID(), "A4", cinema); + + List seats = List.of(seat1, seat2, seat3, seat4, seat5, seat6); + + List seatIds = seats.stream().map(Seat::getSeatId).toList(); + + assertThrows(ConstraintViolationException.class, () -> new ReserveCommandParam(seatIds, UUID.randomUUID(), "user")); + } + + @Test + void testAlreadyReservedSeat() { + // given + UUID cinemaId = UUID.randomUUID(); + Cinema cinema = Cinema.generateCinema(cinemaId, "Test Cinema"); + UUID seat4Id = UUID.randomUUID(); + UUID seat2Id = UUID.randomUUID(); + + Seat seat1 = Seat.generateSeat(UUID.randomUUID(), "A1", cinema); + Seat seat2 = Seat.generateSeat(seat2Id, "A2", cinema); + + UUID screeningId = UUID.randomUUID(); + + Seat seat3 = Seat.generateSeat(seat2Id, "A2", cinema); + Seat seat4 = Seat.generateSeat(seat4Id, "A3", cinema); + List seats = List.of(seat3, seat4); + + List seatIds = seats.stream().map(Seat::getSeatId).toList(); + ReserveCommandParam param = new ReserveCommandParam(seatIds, + screeningId, "user"); + + Reservation reservation = Reservation.generateReservation( + null, null, null, null, List.of(seat1, seat2)); + + when(seatQueryPort.getSeats(List.of(seat2Id, seat4Id))).thenReturn(List.of(seat3, seat4)); + when(reservationQueryPort.getReservations(screeningId)).thenReturn(List.of(reservation)); + + + assertThrows(DataInvalidException.class, () -> reservationCommandService.reserve(param)); + } + + + + @Test + void testAlreadyReservationSeat5Exceed() { + // given + UUID cinemaId = UUID.randomUUID(); + UUID screeningId = UUID.randomUUID(); + Cinema cinema = Cinema.generateCinema(cinemaId, "Test Cinema"); + + Seat seat1 = Seat.generateSeat(UUID.randomUUID(), "A1", cinema); + Seat seat2 = Seat.generateSeat(UUID.randomUUID(), "A2", cinema); + Seat seat3 = Seat.generateSeat(UUID.randomUUID(), "A3", cinema); + Seat seat4 = Seat.generateSeat(UUID.randomUUID(), "A4", cinema); + Seat seat5 = Seat.generateSeat(UUID.randomUUID(), "A5", cinema); + + List alreadyReservedSeats = List.of(seat1, seat2, seat3, seat4, seat5); + + Seat seat6 = Seat.generateSeat(UUID.randomUUID(), "B5", cinema); + + ReserveCommandParam param = new ReserveCommandParam(List.of(seat6.getSeatId()), + screeningId, "user"); + + + Reservation reservation = Reservation.generateReservation( + null, null, null, null, alreadyReservedSeats); + + // when + when(seatQueryPort.getSeats(List.of(seat6.getSeatId()))).thenReturn(List.of(seat6)); + when(reservationQueryPort.getReservations(screeningId)).thenReturn(List.of(reservation)); + + + assertThrows(DataInvalidException.class, () -> reservationCommandService.reserve(param)); + } + +} \ No newline at end of file diff --git a/module-common/build.gradle b/module-common/build.gradle index 5d825a2d9..bd2fe950c 100644 --- a/module-common/build.gradle +++ b/module-common/build.gradle @@ -6,6 +6,7 @@ group = 'project.redis.common' version = '0.0.1-SNAPSHOT' dependencies { + implementation 'org.springframework.boot:spring-boot-starter-validation' testImplementation("org.junit.jupiter:junit-jupiter-api") testImplementation("org.junit.jupiter:junit-jupiter-params") diff --git a/module-common/src/main/java/project/redis/common/SelfValidating.java b/module-common/src/main/java/project/redis/common/SelfValidating.java new file mode 100644 index 000000000..36e578e47 --- /dev/null +++ b/module-common/src/main/java/project/redis/common/SelfValidating.java @@ -0,0 +1,25 @@ +package project.redis.common; + +import static jakarta.validation.Validation.buildDefaultValidatorFactory; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import java.util.Set; + +public abstract class SelfValidating { + private final Validator validator; + + public SelfValidating() { + ValidatorFactory factory = buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + protected void validate() { + Set> violations = validator.validate((T) this); + if (!violations.isEmpty()) { + throw new ConstraintViolationException(violations); + } + } +} diff --git a/module-common/src/main/java/project/redis/common/exception/DataInvalidException.java b/module-common/src/main/java/project/redis/common/exception/DataInvalidException.java new file mode 100644 index 000000000..7e47a0ed8 --- /dev/null +++ b/module-common/src/main/java/project/redis/common/exception/DataInvalidException.java @@ -0,0 +1,16 @@ +package project.redis.common.exception; + + +import lombok.Getter; + +@Getter +public class DataInvalidException extends RuntimeException { + private final ErrorCode errorCode; + private final Object[] args; + + public DataInvalidException(ErrorCode errorCode, Object... args) { + super(errorCode.getMessageId()); + this.errorCode = errorCode; + this.args = args; + } +} diff --git a/module-common/src/main/java/project/redis/common/exception/ErrorCode.java b/module-common/src/main/java/project/redis/common/exception/ErrorCode.java new file mode 100644 index 000000000..22e3e92e4 --- /dev/null +++ b/module-common/src/main/java/project/redis/common/exception/ErrorCode.java @@ -0,0 +1,38 @@ +package project.redis.common.exception; + +import lombok.Getter; + +@Getter +public class ErrorCode { + + private final String messageId; + + public ErrorCode(String messageId) { + this.messageId = messageId; + } + + public static ErrorCode NOT_FOUND = new ErrorCode("COMMON.ERROR.NOT_FOUND"); + public static ErrorCode NOT_NULL = new ErrorCode("COMMON.ERROR.NOT_NULL"); + public static ErrorCode NOT_BLANK = new ErrorCode("COMMON.ERROR.NOT_BLANK"); + + /* SEAT */ + public static ErrorCode SEAT_REQUIRED_SERIES = new ErrorCode( + "SEAT.ERROR.REQUIRED_SERIES" + ); + public static final ErrorCode SEAT_DUPLICATED = new ErrorCode( + "SEAT.ERROR.DUPLICATED" + ); + public static final ErrorCode SEAT_EXCEED_COUNT = new ErrorCode( + "SEAT.ERROR.EXCEED_COUNT" + ); + public static final ErrorCode SEAT_ALREADY_RESERVED = new ErrorCode( + "SEAT.ERROR.ALREADY_RESERVED" + ); + + /* SCREENING */ + public static ErrorCode SCREENING_REQUIRED_LATER_NOW = new ErrorCode( + "SCREENING.ERROR.REQUIRED_LATER_NOW" + ); + + +} diff --git a/module-domain/build.gradle b/module-domain/build.gradle index 724e0a6cb..b0912b853 100644 --- a/module-domain/build.gradle +++ b/module-domain/build.gradle @@ -6,6 +6,7 @@ group = 'project.redis.domain' version = '0.0.1-SNAPSHOT' dependencies { + implementation project(':module-common') testImplementation("org.junit.jupiter:junit-jupiter-api") testImplementation("org.junit.jupiter:junit-jupiter-params") diff --git a/module-domain/src/main/java/project/redis/domain/reservation/Reservation.java b/module-domain/src/main/java/project/redis/domain/reservation/Reservation.java new file mode 100644 index 000000000..aa27effe7 --- /dev/null +++ b/module-domain/src/main/java/project/redis/domain/reservation/Reservation.java @@ -0,0 +1,45 @@ +package project.redis.domain.reservation; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Value; +import project.redis.domain.screening.Screening; +import project.redis.domain.seat.Seat; + +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(force = true) +public class Reservation { + UUID reservationId; + LocalDateTime reservationTime; + String username; + Screening screening; + List seats; + + + public static Reservation generateReservation( + UUID reservationId, LocalDateTime reservationTime, + String username, + Screening screening, + List seats) { + return new Reservation(reservationId, reservationTime, username, screening, seats); + } + + public boolean isSeatAvailable(Seat seat) { + return !seats.contains(seat); + } + + public void addSeats(List newSeats) { + if (seats.size() + newSeats.size() > 5) { + throw new IllegalArgumentException("5개 이상 예약할 수 없습니다"); + } + seats.addAll(newSeats); + } + +} diff --git a/module-domain/src/main/java/project/redis/domain/reservation/ReservationSeat.java b/module-domain/src/main/java/project/redis/domain/reservation/ReservationSeat.java new file mode 100644 index 000000000..2f94e662d --- /dev/null +++ b/module-domain/src/main/java/project/redis/domain/reservation/ReservationSeat.java @@ -0,0 +1,24 @@ +package project.redis.domain.reservation; + + +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Value; + +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(force = true) +public class ReservationSeat { + UUID reservationSeatId; + UUID reservationId; + UUID seatId; + + public static ReservationSeat generateReservationSeat( + UUID reservationSeatId, UUID reservationId, UUID seatId) { + return new ReservationSeat(reservationSeatId, reservationId, seatId); + } +} diff --git a/module-domain/src/main/java/project/redis/domain/screening/Screening.java b/module-domain/src/main/java/project/redis/domain/screening/Screening.java index 7963bc955..ec4687beb 100644 --- a/module-domain/src/main/java/project/redis/domain/screening/Screening.java +++ b/module-domain/src/main/java/project/redis/domain/screening/Screening.java @@ -33,4 +33,9 @@ public static Screening generateScreening( return new Screening(screeningId, screenStartTime, screenEndTime, movie, cinema); } + + public boolean isLaterScreening() { + assert screenStartTime != null; + return screenStartTime.isAfter(LocalDateTime.now()); + } } diff --git a/module-domain/src/main/java/project/redis/domain/seat/Seat.java b/module-domain/src/main/java/project/redis/domain/seat/Seat.java index 9fccfcc67..f8db10011 100644 --- a/module-domain/src/main/java/project/redis/domain/seat/Seat.java +++ b/module-domain/src/main/java/project/redis/domain/seat/Seat.java @@ -1,10 +1,14 @@ package project.redis.domain.seat; +import java.util.ArrayList; +import java.util.List; import java.util.UUID; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.Value; +import project.redis.common.exception.DataInvalidException; +import project.redis.common.exception.ErrorCode; import project.redis.domain.cinema.Cinema; @Getter @@ -18,4 +22,68 @@ public class Seat { public static Seat generateSeat(UUID seatId, String seatNumber, Cinema cinema) { return new Seat(seatId, seatNumber, cinema); } + + public char getColumn() { + return this.seatNumber.charAt(0); + } + + public char getRow() { + return this.seatNumber.charAt(1); + } + + public static boolean isAvailable(List originSeats, List targetSeats) { + List originSeatsIds = originSeats.stream().map(Seat::getSeatId).toList(); + + int sameSeatCount = originSeatsIds.stream() + .filter(seatId -> targetSeats.stream() + .anyMatch(seat -> seat.getSeatId() == seatId)) + .toList().size(); + + if (sameSeatCount > 0) { + throw new DataInvalidException(ErrorCode.SEAT_DUPLICATED); + } + + if (originSeats.size() == 5 || originSeats.size() + targetSeats.size() > 5) { + throw new DataInvalidException(ErrorCode.SEAT_EXCEED_COUNT, 5); + } + + List seats = new ArrayList<>(); + seats.addAll(originSeats); + seats.addAll(targetSeats); + + return isSeries(seats); + } + + public static boolean isSeries(List seats) { + if (seats == null || seats.isEmpty()) { + return false; + } + + char column = seats.getFirst().getColumn(); + + boolean isSameColumn = seats.stream() + .allMatch(seat -> seat.getColumn() == column); + + if (!isSameColumn) { + throw new DataInvalidException(ErrorCode.SEAT_REQUIRED_SERIES); + } + + List rows = seats.stream() + .map(Seat::getRow) + .map(String::valueOf) + .map(Integer::valueOf) + .sorted() + .toList(); + + for (int i = 0; i < rows.size() - 1; i++) { + if (i == rows.size() - 1) { + continue; + } + if (rows.get(i + 1) - rows.get(i) != 1) { + throw new DataInvalidException(ErrorCode.SEAT_REQUIRED_SERIES); + } + } + + return true; + } } diff --git a/module-domain/src/test/java/project/redis/domain/seat/SeatTest.java b/module-domain/src/test/java/project/redis/domain/seat/SeatTest.java new file mode 100644 index 000000000..7a7e572a1 --- /dev/null +++ b/module-domain/src/test/java/project/redis/domain/seat/SeatTest.java @@ -0,0 +1,60 @@ +package project.redis.domain.seat; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import project.redis.domain.cinema.Cinema; + +class SeatTest { + + @Test + void testIsSeries() { + UUID cinemaId = UUID.randomUUID(); + Cinema cinema = Cinema.generateCinema(cinemaId, "Test Cinema"); + + Seat seat1 = Seat.generateSeat(UUID.randomUUID(), "A1", cinema); + Seat seat2 = Seat.generateSeat(UUID.randomUUID(), "A2", cinema); + Seat seat3 = Seat.generateSeat(UUID.randomUUID(), "A3", cinema); + + List seats = List.of(seat1, seat2, seat3); + + boolean result = Seat.isSeries(seats); + + assertThat(result).isTrue(); + } + + @Test + void testIsSeriesNoEqualsColumn() { + UUID cinemaId = UUID.randomUUID(); + Cinema cinema = Cinema.generateCinema(cinemaId, "Test Cinema"); + + Seat seat1 = Seat.generateSeat(UUID.randomUUID(), "A1", cinema); + Seat seat2 = Seat.generateSeat(UUID.randomUUID(), "A2", cinema); + Seat seat3 = Seat.generateSeat(UUID.randomUUID(), "B3", cinema); + + List seats = List.of(seat1, seat2, seat3); + + boolean result = Seat.isSeries(seats); + + assertThat(result).isFalse(); + } + + + @Test + void testIsSeriesNoSeriesRow() { + UUID cinemaId = UUID.randomUUID(); + Cinema cinema = Cinema.generateCinema(cinemaId, "Test Cinema"); + + Seat seat1 = Seat.generateSeat(UUID.randomUUID(), "A1", cinema); + Seat seat2 = Seat.generateSeat(UUID.randomUUID(), "A2", cinema); + Seat seat3 = Seat.generateSeat(UUID.randomUUID(), "A4", cinema); + + List seats = List.of(seat1, seat2, seat3); + + boolean result = Seat.isSeries(seats); + + assertThat(result).isFalse(); + } +} \ No newline at end of file diff --git a/module-infrastructure/build.gradle b/module-infrastructure/build.gradle index 005f7b236..cbdadcb34 100644 --- a/module-infrastructure/build.gradle +++ b/module-infrastructure/build.gradle @@ -33,6 +33,18 @@ dependencies { implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' implementation 'com.querydsl:querydsl-core:5.0.0' + implementation 'org.springframework.retry:spring-retry' + implementation 'org.springframework:spring-aspects' + + // 테스트 컨테이너 + testImplementation 'org.testcontainers:testcontainers' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.testcontainers:mysql' + testImplementation 'org.testcontainers:redis' + + // redisson + implementation 'org.redisson:redisson-spring-boot-starter:3.44.0' + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" diff --git a/module-infrastructure/src/main/generated/project/redis/infrastructure/reservation/entity/QReservationJpaEntity.java b/module-infrastructure/src/main/generated/project/redis/infrastructure/reservation/entity/QReservationJpaEntity.java new file mode 100644 index 000000000..573a2db43 --- /dev/null +++ b/module-infrastructure/src/main/generated/project/redis/infrastructure/reservation/entity/QReservationJpaEntity.java @@ -0,0 +1,69 @@ +package project.redis.infrastructure.reservation.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QReservationJpaEntity is a Querydsl query type for ReservationJpaEntity + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QReservationJpaEntity extends EntityPathBase { + + private static final long serialVersionUID = 635228824L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QReservationJpaEntity reservationJpaEntity = new QReservationJpaEntity("reservationJpaEntity"); + + public final project.redis.infrastructure.common.entity.QBaseJpaEntity _super = new project.redis.infrastructure.common.entity.QBaseJpaEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final ComparablePath createdBy = _super.createdBy; + + public final ComparablePath id = createComparable("id", java.util.UUID.class); + + public final DateTimePath reservationTime = createDateTime("reservationTime", java.time.LocalDateTime.class); + + public final project.redis.infrastructure.screening.entity.QScreeningJpaEntity screening; + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + //inherited + public final ComparablePath updatedBy = _super.updatedBy; + + public final StringPath username = createString("username"); + + public QReservationJpaEntity(String variable) { + this(ReservationJpaEntity.class, forVariable(variable), INITS); + } + + public QReservationJpaEntity(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QReservationJpaEntity(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QReservationJpaEntity(PathMetadata metadata, PathInits inits) { + this(ReservationJpaEntity.class, metadata, inits); + } + + public QReservationJpaEntity(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.screening = inits.isInitialized("screening") ? new project.redis.infrastructure.screening.entity.QScreeningJpaEntity(forProperty("screening"), inits.get("screening")) : null; + } + +} + diff --git a/module-infrastructure/src/main/generated/project/redis/infrastructure/reservation/entity/QReservationSeatJpaEntity.java b/module-infrastructure/src/main/generated/project/redis/infrastructure/reservation/entity/QReservationSeatJpaEntity.java new file mode 100644 index 000000000..4933bfaab --- /dev/null +++ b/module-infrastructure/src/main/generated/project/redis/infrastructure/reservation/entity/QReservationSeatJpaEntity.java @@ -0,0 +1,71 @@ +package project.redis.infrastructure.reservation.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QReservationSeatJpaEntity is a Querydsl query type for ReservationSeatJpaEntity + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QReservationSeatJpaEntity extends EntityPathBase { + + private static final long serialVersionUID = -435679981L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QReservationSeatJpaEntity reservationSeatJpaEntity = new QReservationSeatJpaEntity("reservationSeatJpaEntity"); + + public final project.redis.infrastructure.common.entity.QBaseJpaEntity _super = new project.redis.infrastructure.common.entity.QBaseJpaEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final ComparablePath createdBy = _super.createdBy; + + public final ComparablePath id = createComparable("id", java.util.UUID.class); + + public final QReservationJpaEntity reservation; + + public final project.redis.infrastructure.screening.entity.QScreeningJpaEntity screening; + + public final project.redis.infrastructure.seat.entity.QSeatJpaEntity seat; + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + //inherited + public final ComparablePath updatedBy = _super.updatedBy; + + public QReservationSeatJpaEntity(String variable) { + this(ReservationSeatJpaEntity.class, forVariable(variable), INITS); + } + + public QReservationSeatJpaEntity(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QReservationSeatJpaEntity(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QReservationSeatJpaEntity(PathMetadata metadata, PathInits inits) { + this(ReservationSeatJpaEntity.class, metadata, inits); + } + + public QReservationSeatJpaEntity(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.reservation = inits.isInitialized("reservation") ? new QReservationJpaEntity(forProperty("reservation"), inits.get("reservation")) : null; + this.screening = inits.isInitialized("screening") ? new project.redis.infrastructure.screening.entity.QScreeningJpaEntity(forProperty("screening"), inits.get("screening")) : null; + this.seat = inits.isInitialized("seat") ? new project.redis.infrastructure.seat.entity.QSeatJpaEntity(forProperty("seat"), inits.get("seat")) : null; + } + +} + diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/mapper/CinemaInfraMapper.java b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/mapper/CinemaInfraMapper.java index 1e76781b9..30b6212e3 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/mapper/CinemaInfraMapper.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/mapper/CinemaInfraMapper.java @@ -13,4 +13,11 @@ public static Cinema toCinema(CinemaJpaEntity cinema) { ); } + public static CinemaJpaEntity toEntity(Cinema cinema) { + return CinemaJpaEntity.builder() + .id(cinema.getCinemaId()) + .cinemaName(cinema.getCinemaName()) + .build(); + } + } diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RedisConfig.java b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RedisConfig.java index 9ca6cd173..3dcb74a51 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RedisConfig.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RedisConfig.java @@ -10,6 +10,9 @@ import java.time.Duration; import java.util.HashMap; import java.util.Map; +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; import org.springframework.beans.factory.annotation.Value; import org.springframework.cache.CacheManager; import org.springframework.cache.annotation.EnableCaching; @@ -103,4 +106,11 @@ public KeyGenerator screeningKeyGenerator() { ":genre:" + genreName; }; } + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer().setAddress("redis://" + host + ":" + port); + return Redisson.create(config); + } } diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RetryConfig.java b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RetryConfig.java new file mode 100644 index 000000000..7ab284180 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RetryConfig.java @@ -0,0 +1,28 @@ +package project.redis.infrastructure.common.config; + + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; +import org.springframework.retry.backoff.FixedBackOffPolicy; +import org.springframework.retry.policy.SimpleRetryPolicy; +import org.springframework.retry.support.RetryTemplate; + + +@Configuration +@EnableRetry +public class RetryConfig { + + @Bean("reservationSeatRetryTemplate") + public RetryTemplate reservationSeatRetryTemplate() { + RetryTemplate retryTemplate = new RetryTemplate(); + + retryTemplate.setRetryPolicy(new SimpleRetryPolicy(2)); + + FixedBackOffPolicy fixedBackOffPolicy = new FixedBackOffPolicy(); + fixedBackOffPolicy.setBackOffPeriod(50); + retryTemplate.setBackOffPolicy(fixedBackOffPolicy); + + return retryTemplate; + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/genre/mapper/GenreInfraMapper.java b/module-infrastructure/src/main/java/project/redis/infrastructure/genre/mapper/GenreInfraMapper.java index a64c23647..9b11178c7 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/genre/mapper/GenreInfraMapper.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/genre/mapper/GenreInfraMapper.java @@ -11,4 +11,11 @@ public static Genre toGenre(GenreJpaEntity genre) { genre.getGenreName() ); } + + public static GenreJpaEntity toEntity(Genre genre) { + return GenreJpaEntity.builder() + .id(genre.getGenreId()) + .genreName(genre.getGenreName()) + .build(); + } } diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/movie/mapper/MovieInfraMapper.java b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/mapper/MovieInfraMapper.java index c69756456..570427325 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/movie/mapper/MovieInfraMapper.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/mapper/MovieInfraMapper.java @@ -19,4 +19,16 @@ public static Movie toMovie(MovieJpaEntity movie) { ); } + public static MovieJpaEntity toEntity(Movie movie) { + return MovieJpaEntity.builder() + .id(movie.getMovieId()) + .title(movie.getTitle()) + .rating(movie.getRating()) + .releaseDate(movie.getReleaseDate()) + .thumbnailUrl(movie.getThumbnailUrl()) + .runningMinTime(movie.getRunningMinTime()) + .genre(GenreInfraMapper.toEntity(movie.getGenre())) + .build(); + } + } diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapter.java new file mode 100644 index 000000000..cedd091f7 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapter.java @@ -0,0 +1,40 @@ +package project.redis.infrastructure.reservation.adapter; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import project.redis.application.reservation.port.outbound.ReservationCommandPort; +import project.redis.domain.reservation.Reservation; +import project.redis.domain.seat.Seat; +import project.redis.infrastructure.reservation.entity.ReservationJpaEntity; +import project.redis.infrastructure.reservation.entity.ReservationSeatJpaEntity; +import project.redis.infrastructure.reservation.mapper.ReservationInfraMapper; +import project.redis.infrastructure.reservation.repository.ReservationJpaRepository; +import project.redis.infrastructure.reservation.repository.ReservationSeatJpaRepository; +import project.redis.infrastructure.screening.mapper.ScreeningInfraMapper; +import project.redis.infrastructure.seat.mapper.SeatInfraMapper; + + +@Transactional +@Component +@RequiredArgsConstructor +public class ReservationCommandAdapter implements ReservationCommandPort { + + private final ReservationJpaRepository reservationJpaRepository; + private final ReservationSeatJpaRepository reservationSeatJpaRepository; + + @Override + public void reserve(Reservation reservation) { + ReservationJpaEntity savedReservation = reservationJpaRepository.save(ReservationInfraMapper.toEntity(reservation)); + + for (Seat seat : reservation.getSeats()) { + ReservationSeatJpaEntity reservationSeat = ReservationSeatJpaEntity.builder() + .reservation(savedReservation) + .screening(ScreeningInfraMapper.toEntity(reservation.getScreening())) + .seat(SeatInfraMapper.toEntity(seat)) + .build(); + + reservationSeatJpaRepository.save(reservationSeat); + } + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationLockAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationLockAdapter.java new file mode 100644 index 000000000..3baa287c7 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationLockAdapter.java @@ -0,0 +1,68 @@ +package project.redis.infrastructure.reservation.adapter; + +import java.util.List; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; +import project.redis.application.reservation.port.outbound.ReservationLockPort; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ReservationLockAdapter implements ReservationLockPort { + + private final RedissonClient redissonClient; + + @Override + public boolean tryLock(String lockKey, long waitTimeMils, long releaseTimeMils) { + RLock lock = redissonClient.getLock(lockKey); + try { + return lock.tryLock(waitTimeMils, releaseTimeMils, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + + @Override + public boolean tryScreeningSeatLock(List lockKeys, long waitTimeMils, long releaseTimeMils) { + RLock[] locks = lockKeys.stream() + .map(redissonClient::getLock) + .toArray(RLock[]::new); + + RLock multiLock = redissonClient.getMultiLock(locks); + + try { + log.info("Trying to acquire multi-lock for keys: {}", lockKeys); + boolean acquired = multiLock.tryLock(waitTimeMils, releaseTimeMils, TimeUnit.MILLISECONDS); + log.info("Multi-lock acquired: {}", acquired); + return acquired; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + + @Override + public void releaseLock(String lockKey) { + RLock lock = redissonClient.getLock(lockKey); + if (lock.isHeldByCurrentThread()) { + log.info("Release lock ...{}", lockKey); + lock.unlock(); + } + } + + @Override + public void releaseMultiLock(List lockKeys) { + lockKeys.forEach(lockKey -> { + RLock lock = redissonClient.getLock(lockKey); + if (lock.isHeldByCurrentThread()) { + log.info("Release lock ...{}", lockKey); + lock.unlock(); + } + }); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationQueryAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationQueryAdapter.java new file mode 100644 index 000000000..9be8cb362 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationQueryAdapter.java @@ -0,0 +1,64 @@ +package project.redis.infrastructure.reservation.adapter; + + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; +import project.redis.application.reservation.port.outbound.ReservationQueryPort; +import project.redis.domain.reservation.Reservation; +import project.redis.domain.seat.Seat; +import project.redis.infrastructure.reservation.entity.ReservationJpaEntity; +import project.redis.infrastructure.reservation.entity.ReservationSeatJpaEntity; +import project.redis.infrastructure.reservation.repository.ReservationJpaRepository; +import project.redis.infrastructure.reservation.repository.ReservationSeatJpaRepository; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; +import project.redis.infrastructure.screening.mapper.ScreeningInfraMapper; +import project.redis.infrastructure.seat.mapper.SeatInfraMapper; + +@Component +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReservationQueryAdapter implements ReservationQueryPort { + + private final ReservationJpaRepository reservationJpaRepository; + private final ReservationSeatJpaRepository reservationSeatJpaRepository; + + @Override + public List getReservations(UUID screeningId) { + List reservations = reservationJpaRepository.findAllByScreeningId(screeningId); + + if (CollectionUtils.isEmpty(reservations)) { + return List.of(); + } + + List reservationSeats = reservationSeatJpaRepository.findByScreeningId(screeningId); + + Map> reservationIdToEntityMap = reservationSeats.stream() + .collect(Collectors.groupingBy( + reservationSeatJpaEntity -> reservationSeatJpaEntity.getReservation().getId())); + + return reservations.stream() + .map(reservation -> { + List seats = reservationIdToEntityMap.get(reservation.getId()).stream() + .map(ReservationSeatJpaEntity::getSeat) + .map(SeatInfraMapper::toSeat) + .toList(); + + ScreeningJpaEntity screening = reservation.getScreening(); + + return Reservation.generateReservation( + reservation.getId(), + reservation.getReservationTime(), + reservation.getUsername(), + ScreeningInfraMapper.toScreening(screening), + seats + ); + }) + .toList(); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationJpaEntity.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationJpaEntity.java new file mode 100644 index 000000000..2c4a91fa6 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationJpaEntity.java @@ -0,0 +1,48 @@ +package project.redis.infrastructure.reservation.entity; + + +import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.UuidGenerator; +import project.redis.infrastructure.common.entity.BaseJpaEntity; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; + +@Entity +@Builder +@Table(name = "reservation") +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ReservationJpaEntity extends BaseJpaEntity { + + @Id + @GeneratedValue(generator = "uuid") + @UuidGenerator + @Column(name = "reservation_id", columnDefinition = "BINARY(16)") + private UUID id; + + @Column(nullable = false) + private LocalDateTime reservationTime; + + @Column(nullable = false) + private String username; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "screening_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private ScreeningJpaEntity screening; +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationSeatJpaEntity.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationSeatJpaEntity.java new file mode 100644 index 000000000..a4fdb79c8 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationSeatJpaEntity.java @@ -0,0 +1,56 @@ +package project.redis.infrastructure.reservation.entity; + + +import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.UuidGenerator; +import project.redis.infrastructure.common.entity.BaseJpaEntity; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; +import project.redis.infrastructure.seat.entity.SeatJpaEntity; + +@Entity +@Builder +@Table(name = "reservation_seat", uniqueConstraints = { + @UniqueConstraint( + name = "UK_screening_seat", + columnNames = {"screening_id", "seat_id"} + ) +}) +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ReservationSeatJpaEntity extends BaseJpaEntity { + + @Id + @GeneratedValue(generator = "uuid") + @UuidGenerator + @Column(name = "reservation_seat_id", columnDefinition = "BINARY(16)") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reservation_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private ReservationJpaEntity reservation; + + @ManyToOne + @JoinColumn(name = "screening_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private ScreeningJpaEntity screening; + + @ManyToOne + @JoinColumn(name = "seat_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private SeatJpaEntity seat; +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/mapper/ReservationInfraMapper.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/mapper/ReservationInfraMapper.java new file mode 100644 index 000000000..36106ba1c --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/mapper/ReservationInfraMapper.java @@ -0,0 +1,18 @@ +package project.redis.infrastructure.reservation.mapper; + +import project.redis.domain.reservation.Reservation; +import project.redis.infrastructure.reservation.entity.ReservationJpaEntity; +import project.redis.infrastructure.screening.mapper.ScreeningInfraMapper; + +public class ReservationInfraMapper { + + public static ReservationJpaEntity toEntity(Reservation reservation) { + return ReservationJpaEntity.builder() + .id(reservation.getReservationId()) + .reservationTime(reservation.getReservationTime()) + .username(reservation.getUsername()) + .screening(ScreeningInfraMapper.toEntity(reservation.getScreening())) + .build(); + } + +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationJpaRepository.java new file mode 100644 index 000000000..dfae341b3 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationJpaRepository.java @@ -0,0 +1,17 @@ +package project.redis.infrastructure.reservation.repository; + +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import project.redis.infrastructure.reservation.entity.ReservationJpaEntity; + +public interface ReservationJpaRepository extends JpaRepository { + @EntityGraph(attributePaths = { + "screening", + "screening.movie", + "screening.cinema", + "screening.movie.genre" + }) + List findAllByScreeningId(UUID screeningId); +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationSeatJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationSeatJpaRepository.java new file mode 100644 index 000000000..85c8a1f2e --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationSeatJpaRepository.java @@ -0,0 +1,12 @@ +package project.redis.infrastructure.reservation.repository; + +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import project.redis.infrastructure.reservation.entity.ReservationSeatJpaEntity; + +public interface ReservationSeatJpaRepository extends JpaRepository { + @EntityGraph(attributePaths = {"seat", "seat.cinema"}) + List findByScreeningId(UUID screeningId); +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/adapter/ScreeningQueryAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/adapter/ScreeningQueryAdapter.java index 16901d1bf..27b75f6e5 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/adapter/ScreeningQueryAdapter.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/adapter/ScreeningQueryAdapter.java @@ -4,6 +4,7 @@ import java.time.LocalDate; import java.util.List; +import java.util.UUID; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.cache.annotation.Cacheable; @@ -71,4 +72,10 @@ public List getScreeningsLocalCache(ScreeningQueryFilter filter) { .map(ScreeningInfraMapper::toScreening) .toList(); } + + @Override + public Screening getScreening(UUID screeningId) { + ScreeningJpaEntity screeningEntity = screeningJpaRepository.findByIdOrThrow(screeningId); + return ScreeningInfraMapper.toScreening(screeningEntity); + } } diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/mapper/ScreeningInfraMapper.java b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/mapper/ScreeningInfraMapper.java index 90b2b6da5..2027678df 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/mapper/ScreeningInfraMapper.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/mapper/ScreeningInfraMapper.java @@ -18,4 +18,14 @@ public static Screening toScreening(ScreeningJpaEntity screening) { ); } + public static ScreeningJpaEntity toEntity(Screening screening) { + return ScreeningJpaEntity.builder() + .id(screening.getScreeningId()) + .screeningEndTime(screening.getScreenEndTime()) + .screeningStartTime(screening.getScreenStartTime()) + .movie(MovieInfraMapper.toEntity(screening.getMovie())) + .cinema(CinemaInfraMapper.toEntity(screening.getCinema())) + .build(); + } + } diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepository.java index f659a86ab..bffb40f34 100644 --- a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepository.java +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepository.java @@ -2,10 +2,14 @@ import java.time.LocalDate; import java.util.List; +import java.util.Optional; import java.util.UUID; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import project.redis.common.exception.DataInvalidException; +import project.redis.common.exception.ErrorCode; import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; public interface ScreeningJpaRepository extends JpaRepository, ScreeningJpaRepositoryCustom { @@ -17,4 +21,11 @@ public interface ScreeningJpaRepository extends JpaRepository findAllOrderByReleaseDescAndScreenStartTimeAsc(@Param("limit") LocalDate limit); + + @EntityGraph(attributePaths = {"movie", "cinema", "movie.genre"}) + Optional findOneById(UUID screeningId); + + default ScreeningJpaEntity findByIdOrThrow(UUID id) { + return findOneById(id).orElseThrow(() -> new DataInvalidException(ErrorCode.NOT_FOUND, id)); + } } diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/seat/adapter/SeatQueryAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/adapter/SeatQueryAdapter.java new file mode 100644 index 000000000..41d6cc1f2 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/adapter/SeatQueryAdapter.java @@ -0,0 +1,27 @@ +package project.redis.infrastructure.seat.adapter; + +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import project.redis.application.seat.port.outbound.SeatQueryPort; +import project.redis.domain.seat.Seat; +import project.redis.infrastructure.seat.mapper.SeatInfraMapper; +import project.redis.infrastructure.seat.respository.SeatJpaRepository; + + +@Transactional(readOnly = true) +@Component +@RequiredArgsConstructor +public class SeatQueryAdapter implements SeatQueryPort { + + private final SeatJpaRepository seatJpaRepository; + + @Override + public List getSeats(List seatIds) { + return seatJpaRepository.findByIdIn(seatIds).stream() + .map(SeatInfraMapper::toSeatOnly) + .toList(); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/seat/mapper/SeatInfraMapper.java b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/mapper/SeatInfraMapper.java new file mode 100644 index 000000000..dd5d5c435 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/mapper/SeatInfraMapper.java @@ -0,0 +1,36 @@ +package project.redis.infrastructure.seat.mapper; + +import project.redis.domain.cinema.Cinema; +import project.redis.domain.seat.Seat; +import project.redis.infrastructure.cinema.mapper.CinemaInfraMapper; +import project.redis.infrastructure.seat.entity.SeatJpaEntity; + +public class SeatInfraMapper { + + public static Seat toSeatOnly(SeatJpaEntity seatJpaEntity) { + return Seat.generateSeat( + seatJpaEntity.getId(), + seatJpaEntity.getSeatNumber(), + Cinema.generateCinema(seatJpaEntity.getCinema().getId(), null) + ); + } + + public static Seat toSeat(SeatJpaEntity seatJpaEntity) { + return Seat.generateSeat( + seatJpaEntity.getId(), + seatJpaEntity.getSeatNumber(), + Cinema.generateCinema( + seatJpaEntity.getCinema().getId(), + seatJpaEntity.getCinema().getCinemaName() + ) + ); + } + + public static SeatJpaEntity toEntity(Seat seat) { + return SeatJpaEntity.builder() + .id(seat.getSeatId()) + .cinema(CinemaInfraMapper.toEntity(seat.getCinema())) + .seatNumber(seat.getSeatNumber()) + .build(); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/seat/respository/SeatJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/respository/SeatJpaRepository.java new file mode 100644 index 000000000..6587377a3 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/respository/SeatJpaRepository.java @@ -0,0 +1,16 @@ +package project.redis.infrastructure.seat.respository; + +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import project.redis.infrastructure.seat.entity.SeatJpaEntity; + +public interface SeatJpaRepository extends JpaRepository { + + List findByIdIn(Collection ids); + + @EntityGraph(attributePaths = {"cinema"}) + List findByCinemaId(UUID cinemaId); +} diff --git a/module-infrastructure/src/test/java/project/redis/infrastructure/IntegrationTestConfiguration.java b/module-infrastructure/src/test/java/project/redis/infrastructure/IntegrationTestConfiguration.java new file mode 100644 index 000000000..ac50f3074 --- /dev/null +++ b/module-infrastructure/src/test/java/project/redis/infrastructure/IntegrationTestConfiguration.java @@ -0,0 +1,7 @@ +package project.redis.infrastructure; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication(scanBasePackages = {"project.redis.infrastructure", "project.redis.application"}) +public class IntegrationTestConfiguration { +} diff --git a/module-infrastructure/src/test/java/project/redis/infrastructure/TestConfiguration.java b/module-infrastructure/src/test/java/project/redis/infrastructure/TestConfiguration.java new file mode 100644 index 000000000..9b45dae3d --- /dev/null +++ b/module-infrastructure/src/test/java/project/redis/infrastructure/TestConfiguration.java @@ -0,0 +1,7 @@ +package project.redis.infrastructure; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication(scanBasePackages = {"project.redis.infrastructure"}) +public class TestConfiguration { +} diff --git a/module-infrastructure/src/test/java/project/redis/infrastructure/integration/reservation/ReservationCommandServiceIntegrationTest.java b/module-infrastructure/src/test/java/project/redis/infrastructure/integration/reservation/ReservationCommandServiceIntegrationTest.java new file mode 100644 index 000000000..2333ab9a0 --- /dev/null +++ b/module-infrastructure/src/test/java/project/redis/infrastructure/integration/reservation/ReservationCommandServiceIntegrationTest.java @@ -0,0 +1,144 @@ +package project.redis.infrastructure.integration.reservation; + + +import static java.util.concurrent.Executors.newFixedThreadPool; +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestConstructor; +import project.redis.application.reservation.port.inbound.ReserveCommandParam; +import project.redis.application.reservation.service.ReservationCommandService; +import project.redis.domain.screening.Screening; +import project.redis.domain.seat.Seat; +import project.redis.infrastructure.IntegrationTestConfiguration; +import project.redis.infrastructure.reservation.entity.ReservationJpaEntity; +import project.redis.infrastructure.reservation.entity.ReservationSeatJpaEntity; +import project.redis.infrastructure.reservation.repository.ReservationJpaRepository; +import project.redis.infrastructure.reservation.repository.ReservationSeatJpaRepository; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; +import project.redis.infrastructure.screening.mapper.ScreeningInfraMapper; +import project.redis.infrastructure.screening.repository.ScreeningJpaRepository; +import project.redis.infrastructure.seat.mapper.SeatInfraMapper; +import project.redis.infrastructure.seat.respository.SeatJpaRepository; +import project.redis.infrastructure.util.TestContainerSupport; + +@Slf4j +@SpringBootTest +@ContextConfiguration(classes = IntegrationTestConfiguration.class) +@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) +@RequiredArgsConstructor +public class ReservationCommandServiceIntegrationTest extends TestContainerSupport { + + private final ReservationCommandService reservationCommandService; + + private final ReservationJpaRepository reservationJpaRepository; + private final ReservationSeatJpaRepository reservationSeatJpaRepository; + private final ScreeningJpaRepository screeningJpaRepository; + private final SeatJpaRepository seatJpaRepository; + + @AfterEach + void tearDown() { + reservationSeatJpaRepository.deleteAll(); + reservationJpaRepository.deleteAll(); + } + + @DisplayName("상영 예약 동시성 테스트") + @Test + void testReservationConcurrency() throws InterruptedException { + LocalDate limitDay = LocalDate.now().plusDays(2); + List screenings = screeningJpaRepository.findAllOrderByReleaseDescAndScreenStartTimeAsc( + limitDay); + + Screening screening = screenings.stream() + .filter(screeningJpaEntity -> + screeningJpaEntity.getScreeningStartTime().isAfter(LocalDateTime.now()) + ) + .findFirst() + .map(ScreeningInfraMapper::toScreening) + .get(); + + List seatIds = seatJpaRepository.findByCinemaId(screening.getCinema().getCinemaId()).stream() + .filter(seatJpaEntity -> + seatJpaEntity.getSeatNumber().contains("A")) + .map(SeatInfraMapper::toSeat) + .map(Seat::getSeatId) + .collect(Collectors.toList()); + + int threadCount = 10; + ExecutorService executorService = newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + for (int i = 0; i < threadCount; i++) { + int finalI = i; + executorService.execute(() -> { + try { + + ReserveCommandParam param = new ReserveCommandParam(seatIds, screening.getScreeningId(), + "user-" + Thread.currentThread().getId()); + reservationCommandService.reserve(param); + + } catch (Exception e) { + log.info("failed to reserve reservation {}", finalI); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + List reservations = reservationJpaRepository.findAll(); + List reservationSeats = reservationSeatJpaRepository.findAll(); + + assertThat(reservations.size()).isEqualTo(1); + assertThat(reservationSeats.size()).isEqualTo(5); + } + + + @DisplayName("상영 예약 테스트") + @Test + void testReservation() throws InterruptedException { + LocalDate limitDay = LocalDate.now().plusDays(2); + List screenings = screeningJpaRepository.findAllOrderByReleaseDescAndScreenStartTimeAsc( + limitDay); + + Screening screening = screenings.stream() + .filter(screeningJpaEntity -> + screeningJpaEntity.getScreeningStartTime().isAfter(LocalDateTime.now()) + ) + .findFirst() + .map(ScreeningInfraMapper::toScreening) + .get(); + + List seatIds = seatJpaRepository.findByCinemaId(screening.getCinema().getCinemaId()).stream() + .filter(seatJpaEntity -> + seatJpaEntity.getSeatNumber().contains("A")) + .map(SeatInfraMapper::toSeat) + .map(Seat::getSeatId) + .collect(Collectors.toList()); + + ReserveCommandParam param = new ReserveCommandParam(seatIds, screening.getScreeningId(), + "user-" + Thread.currentThread().getId()); + reservationCommandService.reserve(param); + + List reservations = reservationJpaRepository.findAll(); + List reservationSeats = reservationSeatJpaRepository.findAll(); + + assertThat(reservations.size()).isEqualTo(1); + assertThat(reservationSeats.size()).isEqualTo(5); + } +} diff --git a/module-infrastructure/src/test/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapterTest.java b/module-infrastructure/src/test/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapterTest.java new file mode 100644 index 000000000..df3be794d --- /dev/null +++ b/module-infrastructure/src/test/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapterTest.java @@ -0,0 +1,143 @@ +package project.redis.infrastructure.reservation.adapter; + +import static java.util.concurrent.Executors.newFixedThreadPool; +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestConstructor; +import project.redis.domain.reservation.Reservation; +import project.redis.domain.screening.Screening; +import project.redis.domain.seat.Seat; +import project.redis.infrastructure.TestConfiguration; +import project.redis.infrastructure.reservation.entity.ReservationJpaEntity; +import project.redis.infrastructure.reservation.entity.ReservationSeatJpaEntity; +import project.redis.infrastructure.reservation.repository.ReservationJpaRepository; +import project.redis.infrastructure.reservation.repository.ReservationSeatJpaRepository; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; +import project.redis.infrastructure.screening.mapper.ScreeningInfraMapper; +import project.redis.infrastructure.screening.repository.ScreeningJpaRepository; +import project.redis.infrastructure.seat.mapper.SeatInfraMapper; +import project.redis.infrastructure.seat.respository.SeatJpaRepository; +import project.redis.infrastructure.util.TestContainerSupport; + + +@Slf4j +@SpringBootTest +@ContextConfiguration(classes = TestConfiguration.class) +@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) +@RequiredArgsConstructor +class ReservationCommandAdapterTest extends TestContainerSupport { + + private final ReservationCommandAdapter reservationCommandAdapter; + private final ReservationJpaRepository reservationJpaRepository; + private final ReservationSeatJpaRepository reservationSeatJpaRepository; + private final SeatJpaRepository seatJpaRepository; + private final ScreeningJpaRepository screeningJpaRepository; + + @AfterEach + void tearDown() { + reservationSeatJpaRepository.deleteAll(); + reservationJpaRepository.deleteAll(); + } + + @Test + void testReserve() { + LocalDate limitDay = LocalDate.now().plusDays(2); + List screenings = screeningJpaRepository.findAllOrderByReleaseDescAndScreenStartTimeAsc( + limitDay); + + Screening screening = screenings.stream() + .filter(screeningJpaEntity -> + screeningJpaEntity.getScreeningStartTime().isAfter(LocalDateTime.now()) + ) + .findFirst() + .map(ScreeningInfraMapper::toScreening) + .get(); + + List seats = seatJpaRepository.findByCinemaId(screening.getCinema().getCinemaId()).stream() + .filter(seatJpaEntity -> + seatJpaEntity.getSeatNumber().contains("A")) + .map(SeatInfraMapper::toSeat) + .toList(); + + Reservation reservation = Reservation.generateReservation( + null, + LocalDateTime.now(), + "hongs", + screening, + seats); + + reservationCommandAdapter.reserve(reservation); + + List reservations = reservationJpaRepository.findAll(); + List reservationSeats = reservationSeatJpaRepository.findAll(); + + assertThat(reservations.size()).isEqualTo(1); + assertThat(reservationSeats.size()).isEqualTo(5); + } + + @Test + void testReserveConcurrencyTest() throws InterruptedException { + LocalDate limitDay = LocalDate.now().plusDays(2); + List screenings = screeningJpaRepository.findAllOrderByReleaseDescAndScreenStartTimeAsc( + limitDay); + + Screening screening = screenings.stream() + .filter(screeningJpaEntity -> + screeningJpaEntity.getScreeningStartTime().isAfter(LocalDateTime.now()) + ) + .findFirst() + .map(ScreeningInfraMapper::toScreening) + .get(); + + List seats = seatJpaRepository.findByCinemaId(screening.getCinema().getCinemaId()).stream() + .filter(seatJpaEntity -> + seatJpaEntity.getSeatNumber().contains("A")) + .map(SeatInfraMapper::toSeat) + .toList(); + + int threadCount = 10; + ExecutorService executorService = newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + for (int i = 0; i < threadCount; i++) { + int finalI = i; + executorService.execute(() -> { + try { + Reservation reservation = Reservation.generateReservation( + null, + LocalDateTime.now(), + "user-" + Thread.currentThread().getId(), + screening, + seats + ); + + reservationCommandAdapter.reserve(reservation); + } catch (Exception e) { + log.info("failed to reserve reservation {}", finalI); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + List reservations = reservationJpaRepository.findAll(); + List reservationSeats = reservationSeatJpaRepository.findAll(); + + assertThat(reservations.size()).isEqualTo(1); + assertThat(reservationSeats.size()).isEqualTo(5); + } +} \ No newline at end of file diff --git a/module-infrastructure/src/test/java/project/redis/infrastructure/util/ScreeningDataInit.java b/module-infrastructure/src/test/java/project/redis/infrastructure/util/ScreeningDataInit.java new file mode 100644 index 000000000..7a5b93279 --- /dev/null +++ b/module-infrastructure/src/test/java/project/redis/infrastructure/util/ScreeningDataInit.java @@ -0,0 +1,70 @@ +package project.redis.infrastructure.util; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; +import project.redis.infrastructure.cinema.entity.CinemaJpaEntity; +import project.redis.infrastructure.cinema.repository.CinemaJpaRepository; +import project.redis.infrastructure.movie.entity.MovieJpaEntity; +import project.redis.infrastructure.movie.repository.MovieJpaRepository; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; +import project.redis.infrastructure.screening.repository.ScreeningJpaRepository; + +@Component +@RequiredArgsConstructor +public class ScreeningDataInit implements CommandLineRunner { + + private final ScreeningJpaRepository screeningJpaRepository; + private final MovieJpaRepository movieJpaRepository; + private final CinemaJpaRepository cinemaJpaRepository; + + private static final Random RANDOM = new Random(); + + @Override + public void run(String... args) throws Exception { + List movies = movieJpaRepository.findAll(); + List cinemas = cinemaJpaRepository.findAll(); + + Stream.iterate(0, i -> i + 1) + .limit(500) + .parallel() + .map(index -> { + + MovieJpaEntity movieJpaEntity = movies.get(RANDOM.nextInt(movies.size())); + CinemaJpaEntity cinemaJpaEntity = cinemas.get(RANDOM.nextInt(cinemas.size())); + + LocalDateTime startTime = generateRandomStartTime(); + LocalDateTime endTime = startTime.plusMinutes(movieJpaEntity.getRunningMinTime()); + + return ScreeningJpaEntity.builder() + .screeningStartTime(startTime) + .screeningEndTime(endTime) + .movie(movieJpaEntity) + .cinema(cinemaJpaEntity) + .build(); + + }) + .forEach(screeningJpaRepository::save); + + } + + public LocalDateTime generateRandomStartTime() { + LocalDate today = LocalDate.now(); + LocalDate startDate = today.plusDays(1); + LocalDate endDate = today.plusDays(20); + + long randomDays = ThreadLocalRandom.current() + .nextLong(ChronoUnit.DAYS.between(startDate, endDate) + 1); + + return startDate + .plusDays(randomDays) + .atTime(new Random().nextInt(18), new Random().nextInt(60)); + } +} diff --git a/module-infrastructure/src/test/java/project/redis/infrastructure/util/TestContainerSupport.java b/module-infrastructure/src/test/java/project/redis/infrastructure/util/TestContainerSupport.java new file mode 100644 index 000000000..e0a7ec1fb --- /dev/null +++ b/module-infrastructure/src/test/java/project/redis/infrastructure/util/TestContainerSupport.java @@ -0,0 +1,44 @@ +package project.redis.infrastructure.util; + +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; + + +public abstract class TestContainerSupport { + + @Container + public static final MySQLContainer mysqlContainer + = new MySQLContainer<>("mysql:9.1.0") + .withDatabaseName("db") + .withUsername("user") + .withPassword("1234") + .withCommand( + "--character-set-server=utf8mb4", + "--collation-server=utf8mb4_unicode_ci" + ); + + @Container + public static final GenericContainer redisContainer + = new GenericContainer<>("redis") + .withExposedPorts(6379); + + static { + mysqlContainer.start(); + redisContainer.start(); + } + + @DynamicPropertySource + static void setDatasourceProperties(DynamicPropertyRegistry registry) { + System.out.println("mysqlContainer = " + mysqlContainer.getJdbcUrl()); + registry.add("spring.datasource.url", ()-> mysqlContainer.getJdbcUrl() + "?useSSL=false&allowPublicKeyRetrieval=true"); + registry.add("spring.datasource.username", mysqlContainer::getUsername); + registry.add("spring.datasource.password", mysqlContainer::getPassword); + registry.add("spring.datasource.driver-class-name", mysqlContainer::getDriverClassName); + + registry.add("redis.host", redisContainer::getHost); + registry.add("redis.port", () -> redisContainer.getMappedPort(6379).toString()); + } +} diff --git a/module-infrastructure/src/test/resources/application.yaml b/module-infrastructure/src/test/resources/application.yaml new file mode 100644 index 000000000..e389ed929 --- /dev/null +++ b/module-infrastructure/src/test/resources/application.yaml @@ -0,0 +1,6 @@ +spring: + flyway: + enabled: true + baseline-on-migrate: true + placeholder-replacement: false + locations: classpath:db/migration diff --git a/module-infrastructure/src/test/resources/db/migration/V1__CreateInitTable.sql b/module-infrastructure/src/test/resources/db/migration/V1__CreateInitTable.sql new file mode 100644 index 000000000..dff8960ba --- /dev/null +++ b/module-infrastructure/src/test/resources/db/migration/V1__CreateInitTable.sql @@ -0,0 +1,75 @@ +-- genre 테이블 생성 +create table genre +( + genre_id binary(16) not null primary key, + genre_name varchar(255) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null +) engine = innodb + charset = utf8mb4; + +-- movie 테이블 생성 +create table movie +( + movie_id binary(16) not null primary key, + title varchar(255) not null, + rating varchar(255) not null, + release_date date not null, + thumbnail_url text, + running_min_time int not null, + genre_id binary(16) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null, + constraint fk_movie_genre foreign key (genre_id) references genre (genre_id) +) engine = innodb + charset = utf8mb4; + + +-- cinema 테이블 생성 +create table cinema +( + cinema_id binary(16) not null primary key, + cinema_name varchar(255) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null +) engine = innodb + charset = utf8mb4; + + +-- screening 테이블 생성 +create table screening +( + screening_id binary(16) not null primary key, + screening_start_time datetime(6) not null, + screening_end_time datetime(6) not null, + movie_id binary(16) not null, + cinema_id binary(16) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null, + constraint fk_screening_movie foreign key (movie_id) references movie (movie_id), + constraint fk_screening_cinema foreign key (cinema_id) references cinema (cinema_id) +) engine = innodb + charset = utf8mb4; + + +-- seat 테이블 생성 +create table seat +( + seat_id binary(16) not null primary key, + seat_number varchar(255) not null, + cinema_id binary(16) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null, + constraint fk_seat_cinema foreign key (cinema_id) references cinema (cinema_id) +) engine = innodb + charset = utf8mb4; \ No newline at end of file diff --git a/module-infrastructure/src/test/resources/db/migration/V2__InitData.sql b/module-infrastructure/src/test/resources/db/migration/V2__InitData.sql new file mode 100644 index 000000000..ae31805dc --- /dev/null +++ b/module-infrastructure/src/test/resources/db/migration/V2__InitData.sql @@ -0,0 +1,180 @@ +insert into genre (genre_id, genre_name) +values (uuid_to_bin(uuid()), '액션'), + (uuid_to_bin(uuid()), '코미디'), + (uuid_to_bin(uuid()), '드라마'), + (uuid_to_bin(uuid()), '판타지'), + (uuid_to_bin(uuid()), '로맨스'); + +insert into cinema (cinema_id, cinema_name) +values (uuid_to_bin(uuid()), '스타라이트 상영관'), + (uuid_to_bin(uuid()), '드림씨어터'), + (uuid_to_bin(uuid()), '선셋 극장'), + (uuid_to_bin(uuid()), '루프탑 상영관'), + (uuid_to_bin(uuid()), '클래식 상영관'); + +insert into seat (seat_id, seat_number, cinema_id) +values + -- 루프탑 상영관 좌석 + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + + -- 클래식 상영관 좌석 + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '선셋 극장')); + + +insert into movie (movie_id, title, rating, release_date, thumbnail_url, running_min_time, genre_id) +values + -- 액션 장르 영화 + (uuid_to_bin(uuid()), '매드 맥스: 분노의 도로', 'NINETEEN', '2015-05-15', 'https://example.com/madmax.jpg', 120, + (select genre_id from genre where genre_name = '액션')), + (uuid_to_bin(uuid()), '다이하드', 'NINETEEN', '1988-07-20', 'https://example.com/diehard.jpg', 131, + (select genre_id from genre where genre_name = '액션')), + + -- 코미디 장르 영화 + (uuid_to_bin(uuid()), '슈퍼배드', 'TWELVE', '2010-07-09', 'https://example.com/despicableme.jpg', 95, + (select genre_id from genre where genre_name = '코미디')), + (uuid_to_bin(uuid()), '트루먼 쇼', 'TWELVE', '1998-06-05', 'https://example.com/trumanshow.jpg', 103, + (select genre_id from genre where genre_name = '코미디')), + + -- 드라마 장르 영화 + (uuid_to_bin(uuid()), '쇼생크 탈출', 'FIFTEEN', '1994-09-23', 'https://example.com/shawshank.jpg', 142, + (select genre_id from genre where genre_name = '드라마')), + (uuid_to_bin(uuid()), '포레스트 검프', 'TWELVE', '1994-07-06', 'https://example.com/forrestgump.jpg', 144, + (select genre_id from genre where genre_name = '드라마')), + + -- 판타지 장르 영화 + (uuid_to_bin(uuid()), '반지의 제왕: 반지 원정대', 'FIFTEEN', '2001-12-19', 'https://example.com/lotr.jpg', 178, + (select genre_id from genre where genre_name = '판타지')), + (uuid_to_bin(uuid()), '해리 포터와 마법사의 돌', 'TWELVE', '2001-11-16', 'https://example.com/harrypotter.jpg', 152, + (select genre_id from genre where genre_name = '판타지')), + + -- 로맨스 장르 영화 + (uuid_to_bin(uuid()), '타이타닉', 'FIFTEEN', '1997-12-19', 'https://example.com/titanic.jpg', 195, + (select genre_id from genre where genre_name = '로맨스')), + (uuid_to_bin(uuid()), '노트북', 'FIFTEEN', '2004-06-25', 'https://example.com/notebook.jpg', 123, + (select genre_id from genre where genre_name = '로맨스')); diff --git a/module-infrastructure/src/test/resources/db/migration/V3__DropForeignKeyAllTables.sql b/module-infrastructure/src/test/resources/db/migration/V3__DropForeignKeyAllTables.sql new file mode 100644 index 000000000..b2e4cd447 --- /dev/null +++ b/module-infrastructure/src/test/resources/db/migration/V3__DropForeignKeyAllTables.sql @@ -0,0 +1,4 @@ +ALTER TABLE movie DROP FOREIGN KEY fk_movie_genre; +ALTER TABLE screening DROP FOREIGN KEY fk_screening_movie; +ALTER TABLE screening DROP FOREIGN KEY fk_screening_cinema; +ALTER TABLE seat DROP FOREIGN KEY fk_seat_cinema; \ No newline at end of file diff --git a/module-infrastructure/src/test/resources/db/migration/V4__CreateReservationRelatedTables.sql b/module-infrastructure/src/test/resources/db/migration/V4__CreateReservationRelatedTables.sql new file mode 100644 index 000000000..7decc80c5 --- /dev/null +++ b/module-infrastructure/src/test/resources/db/migration/V4__CreateReservationRelatedTables.sql @@ -0,0 +1,25 @@ +create table reservation +( + reservation_id binary(16) not null primary key, + username varchar(255) not null, + screening_id binary(16) not null, + reservation_time datetime(6) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null +) engine=innodb charset=utf8mb4; + + +create table reservation_seat +( + reservation_seat_id binary(16) not null primary key, + reservation_id binary(16) not null, + screening_id binary(16) not null, + seat_id binary(16) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null, + constraint UK_screening_seat unique (screening_id, seat_id) +) engine=innodb charset=utf8mb4; \ No newline at end of file diff --git a/module-presentation/src/main/java/project/redis/presentation/TheaterApplication.java b/module-presentation/src/main/java/project/redis/presentation/TheaterApplication.java index 03af43013..8fafd0fa5 100644 --- a/module-presentation/src/main/java/project/redis/presentation/TheaterApplication.java +++ b/module-presentation/src/main/java/project/redis/presentation/TheaterApplication.java @@ -1,8 +1,14 @@ package project.redis.presentation; +import java.util.Locale; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.support.ReloadableResourceBundleMessageSource; +import org.springframework.web.servlet.LocaleResolver; +import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver; @SpringBootApplication(scanBasePackages = { @@ -15,4 +21,19 @@ public class TheaterApplication { public static void main(String[] args) { SpringApplication.run(TheaterApplication.class, args); } + + @Bean + public MessageSource messageSource() { + ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource(); + messageSource.setBasename("classpath:i18n/messages"); + messageSource.setDefaultEncoding("UTF-8"); + return messageSource; + } + + @Bean + public LocaleResolver localeResolver() { + AcceptHeaderLocaleResolver localeResolver = new AcceptHeaderLocaleResolver(); + localeResolver.setDefaultLocale(Locale.KOREA); + return localeResolver; + } } diff --git a/module-presentation/src/main/java/project/redis/presentation/cinema/controller/CinemaController.java b/module-presentation/src/main/java/project/redis/presentation/cinema/controller/CinemaController.java index b643cf6fc..36798ce72 100644 --- a/module-presentation/src/main/java/project/redis/presentation/cinema/controller/CinemaController.java +++ b/module-presentation/src/main/java/project/redis/presentation/cinema/controller/CinemaController.java @@ -1,7 +1,6 @@ package project.redis.presentation.cinema.controller; -import jakarta.validation.Valid; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -39,12 +38,11 @@ public ResponseEntity> getCinemas() { } @PostMapping - public ResponseEntity createCinema(@RequestBody @Valid CinemaCreateRequest request) { - cinemaCommandUseCase.createCinema( - CinemaCreateCommandParam.builder() - .CinemaName(request.getCinemaName()) - .build() - ); + public ResponseEntity createCinema(@RequestBody CinemaCreateRequest request) { + + CinemaCreateCommandParam command = new CinemaCreateCommandParam(request.getCinemaName()); + + cinemaCommandUseCase.createCinema(command); return ResponseEntity.status(HttpStatus.CREATED).build(); } diff --git a/module-presentation/src/main/java/project/redis/presentation/cinema/exception/ErrorResponse.java b/module-presentation/src/main/java/project/redis/presentation/cinema/exception/ErrorResponse.java new file mode 100644 index 000000000..4cb762746 --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/cinema/exception/ErrorResponse.java @@ -0,0 +1,12 @@ +package project.redis.presentation.cinema.exception; + + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ErrorResponse { + private String errorCode; + private String errorMessage; +} diff --git a/module-presentation/src/main/java/project/redis/presentation/cinema/exception/GlobalExceptionHandler.java b/module-presentation/src/main/java/project/redis/presentation/cinema/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..6619cd62b --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/cinema/exception/GlobalExceptionHandler.java @@ -0,0 +1,54 @@ +package project.redis.presentation.cinema.exception; + + +import jakarta.validation.ConstraintViolationException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import project.redis.common.exception.DataInvalidException; + +@RestControllerAdvice +@RequiredArgsConstructor +public class GlobalExceptionHandler { + + private final MessageSource messageSource; + + @ExceptionHandler({ConstraintViolationException.class}) + public ResponseEntity>> handleConstraintViolationException( + ConstraintViolationException e, Locale locale) { + Map> errors = new HashMap<>(); + e.getConstraintViolations() + .forEach(constraintViolation -> { + String errorCode = constraintViolation.getMessageTemplate(); + String message = messageSource.getMessage(errorCode, null, locale); + + errors.computeIfAbsent( + constraintViolation.getPropertyPath().toString(), + key -> new ArrayList<>() + ).add(new ErrorResponse(errorCode, message)); + + }); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST.value()).body(errors); + } + + @ExceptionHandler(DataInvalidException.class) + public ResponseEntity handleDataInvalidException( + DataInvalidException e, Locale locale) { + String errorCode = e.getErrorCode().getMessageId(); + Object[] args = e.getArgs(); + String message = messageSource.getMessage(errorCode, args, locale); + + ErrorResponse errorResponse = new ErrorResponse(errorCode, message); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } +} diff --git a/module-presentation/src/main/java/project/redis/presentation/reservation/controller/ReservationController.java b/module-presentation/src/main/java/project/redis/presentation/reservation/controller/ReservationController.java new file mode 100644 index 000000000..16e9957a9 --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/reservation/controller/ReservationController.java @@ -0,0 +1,32 @@ +package project.redis.presentation.reservation.controller; + + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import project.redis.application.reservation.port.inbound.ReservationCommandUseCase; +import project.redis.application.reservation.port.inbound.ReserveCommandParam; +import project.redis.presentation.reservation.dto.request.ReservationCommandRequest; + +@RestController +@RequestMapping("/api/v1/reservations") +@RequiredArgsConstructor +public class ReservationController { + + private final ReservationCommandUseCase reservationCommandUseCase; + + @PostMapping + public ResponseEntity createReservation(@RequestBody ReservationCommandRequest request) { + + ReserveCommandParam param = new ReserveCommandParam(request.getSeatIds(), + request.getScreeningId(), request.getUsername()); + + reservationCommandUseCase.reserve(param); + + return ResponseEntity.status(HttpStatus.CREATED).build(); + } +} diff --git a/module-presentation/src/main/java/project/redis/presentation/reservation/dto/request/ReservationCommandRequest.java b/module-presentation/src/main/java/project/redis/presentation/reservation/dto/request/ReservationCommandRequest.java new file mode 100644 index 000000000..20bc30f4b --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/reservation/dto/request/ReservationCommandRequest.java @@ -0,0 +1,16 @@ +package project.redis.presentation.reservation.dto.request; + +import java.util.List; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ReservationCommandRequest { + private String username; + private UUID screeningId; + private List seatIds; +} diff --git a/module-presentation/src/main/resources/application.yaml b/module-presentation/src/main/resources/application.yaml index 1c3292498..f80206d62 100644 --- a/module-presentation/src/main/resources/application.yaml +++ b/module-presentation/src/main/resources/application.yaml @@ -13,6 +13,9 @@ spring: properties: hibernate: format_sql: true + jdbc: + batch_size: 100 + flyway: enabled: true @@ -20,6 +23,10 @@ spring: placeholder-replacement: false locations: classpath:db/migration + messages: + basename: i18n/messages + + logging: level: org: diff --git a/module-presentation/src/main/resources/db/migration/V4__CreateReservationRelatedTables.sql b/module-presentation/src/main/resources/db/migration/V4__CreateReservationRelatedTables.sql new file mode 100644 index 000000000..7decc80c5 --- /dev/null +++ b/module-presentation/src/main/resources/db/migration/V4__CreateReservationRelatedTables.sql @@ -0,0 +1,25 @@ +create table reservation +( + reservation_id binary(16) not null primary key, + username varchar(255) not null, + screening_id binary(16) not null, + reservation_time datetime(6) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null +) engine=innodb charset=utf8mb4; + + +create table reservation_seat +( + reservation_seat_id binary(16) not null primary key, + reservation_id binary(16) not null, + screening_id binary(16) not null, + seat_id binary(16) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null, + constraint UK_screening_seat unique (screening_id, seat_id) +) engine=innodb charset=utf8mb4; \ No newline at end of file diff --git a/module-presentation/src/main/resources/i18n/messages_en.properties b/module-presentation/src/main/resources/i18n/messages_en.properties new file mode 100644 index 000000000..0bd50c5ef --- /dev/null +++ b/module-presentation/src/main/resources/i18n/messages_en.properties @@ -0,0 +1,14 @@ +COMMON.ERROR.NOT_FOUND=Not found resource. +COMMON.ERROR.NOT_NULL=Not null this property. +COMMON.ERROR.NOT_BLANK=Not blank this property. +# +# SEAT +# +SEAT.ERROR.REQUIRED_SERIES=Reservations can only be made for consecutive seats +SEAT.ERROR.DUPLICATED=Some of the seats have already been reserved. +SEAT.ERROR.EXCEED_COUNT=Seats can not be reserved over {0} count. +SEAT.ERROR.ALREADY_RESERVED=The seats already reserved exist. {0} +# +# SCREENING +# +SCREENING.ERROR.REQUIRED_LATER_NOW=This screening schedule has already passed. {0} \ No newline at end of file diff --git a/module-presentation/src/main/resources/i18n/messages_ko.properties b/module-presentation/src/main/resources/i18n/messages_ko.properties new file mode 100644 index 000000000..949f73083 --- /dev/null +++ b/module-presentation/src/main/resources/i18n/messages_ko.properties @@ -0,0 +1,14 @@ +COMMON.ERROR.NOT_FOUND=\uB9AC\uC18C\uC2A4\uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. +COMMON.ERROR.NOT_NULL=\uB110 \uC774\uC5B4\uC11C\uB294 \uC548\uB429\uB2C8\uB2E4. +COMMON.ERROR.NOT_BLANK=\uBE48 \uAC12\uC774\uC5B4\uC11C\uB294 \uC548\uB429\uB2C8\uB2E4. +# +# SEAT +# +SEAT.ERROR.REQUIRED_SERIES=\uC5F0\uC18D\uB41C \uC88C\uC11D\uC73C\uB85C\uB9CC \uC608\uC57D\uC774 \uAC00\uB2A5\uD569\uB2C8\uB2E4. +SEAT.ERROR.DUPLICATED=\uC77C\uBD80 \uC88C\uC11D\uC774 \uC774\uBBF8 \uC608\uC57D \uB418\uC5C8\uC2B5\uB2C8\uB2E4. +SEAT.ERROR.EXCEED_COUNT=\uC88C\uC11D\uC740 {0} \uAC1C \uC774\uC0C1 \uC608\uC57D\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. +SEAT.ERROR.ALREADY_RESERVED=\uC774\uBBF8 \uC608\uC57D\uB41C \uC88C\uC11D\uC774 \uC874\uC7AC\uD569\uB2C8\uB2E4. {0} +# +# SCREENING +# +SCREENING.ERROR.REQUIRED_LATER_NOW=\uC774\uBBF8 \uC9C0\uB09C \uC0C1\uC601\uC2DC\uAC04\uD45C \uC785\uB2C8\uB2E4. {0} \ No newline at end of file