Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions module-application/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
@@ -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<CinemaCreateCommandParam> {

@NotNull(message = "COMMON.ERROR.NOT_NULL")
@NotBlank(message = "COMMON.ERROR.NOT_BLANK")
String cinemaName;

public CinemaCreateCommandParam(String cinemaName) {
this.cinemaName = cinemaName;
this.validate();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package project.redis.application.reservation.port.inbound;

public interface ReservationCommandUseCase {

boolean reserve(ReserveCommandParam param);
}
Original file line number Diff line number Diff line change
@@ -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<ReserveCommandParam> {

@NotNull
@Size(min = 1, max = 5)
List<UUID> seatIds;

@NotNull
UUID screeningId;

@NotNull
String userName;

public ReserveCommandParam(List<UUID> seatIds, UUID screeningId, String userName) {
this.seatIds = seatIds;
this.screeningId = screeningId;
this.userName = userName;
validate();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package project.redis.application.reservation.port.outbound;

import project.redis.domain.reservation.Reservation;

public interface ReservationCommandPort {
void reserve(Reservation reservation);
}
Original file line number Diff line number Diff line change
@@ -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<String> lockKeys, long waitTimeMils, long releaseTimeMils);

void releaseLock(String lockKey);

void releaseMultiLock(List<String> lockKeys);
}
Original file line number Diff line number Diff line change
@@ -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<Reservation> getReservations(UUID screeningId);
}
Original file line number Diff line number Diff line change
@@ -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<String> 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<Seat> seats = seatQueryPort.getSeats(param.getSeatIds());
if (!Seat.isSeries(seats)) {
throw new DataInvalidException(ErrorCode.SEAT_REQUIRED_SERIES);
}

// 예약 가져오기
List<Reservation> originReservations = reservationQueryPort.getReservations(param.getScreeningId());

List<UUID> 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<Seat> 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<String> makeLockKey(String screeningId, List<String> list) {
return list.stream()
.map(seatId -> "reservation-lock:" + screeningId + ":" + seatId)
.toList();
}
}
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -10,4 +11,6 @@ public interface ScreeningQueryPort {
List<Screening> getScreeningsRedis(ScreeningQueryFilter filter);

List<Screening> getScreeningsLocalCache(ScreeningQueryFilter filter);

Screening getScreening(UUID screeningId);
}
Original file line number Diff line number Diff line change
@@ -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<Seat> getSeats(List<UUID> seatIds);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand All @@ -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());
Expand Down
Loading