diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..a95432f95 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Spring Boot +*.log +*.class + +# Gradle +.gradle/ + +# IntelliJ IDEA +.idea/ \ No newline at end of file diff --git a/README.md b/README.md index 5fcc66b4d..6cacb06fc 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,78 @@ -## [본 과정] 이커머스 핵심 프로세스 구현 -[단기 스킬업 Redis 교육 과정](https://hh-skillup.oopy.io/) 을 통해 상품 조회 및 주문 과정을 구현하며 현업에서 발생하는 문제를 Redis의 핵심 기술을 통해 해결합니다. -> Indexing, Caching을 통한 성능 개선 / 단계별 락 구현을 통한 동시성 이슈 해결 (낙관적/비관적 락, 분산락 등) +# [본 과정] 이커머스 핵심 프로세스 구현 +- 분산 캐시를 적용한 영화 조회 API 구현 +- 분산 락을 적용한 영화 자리 예매 API 구현 +- 분산 Rate Limit을 적용한 API 요청 속도 및 횟수 제어 구현 + +## How to use + +```bash +docker compose up -d +``` +```bash +curl -X GET http://localhost:8080/api/v1/movies +``` + +## Multi Module + +### 1-1. movie-api +> 영화 도메인 presentation 담당합니다. + +### 1-2. booking-api +> 예약 도메인 presentation 담당합니다. + +### 2. application +> Use Case 생성을 담당합니다. + +### 3. infrastructure +> DB 연결과 Entity 관리를 담당합니다. + +### 4. domain +> 도메인 로직을 포함합니다. + +## Architecture +> 클린 아키텍처를 지향합니다. + +![arc](etc/readme/arc3.png) + +## Table Design +![erd_db](etc/readme/erd2.png) + +## 성능 테스트 +### 캐싱할 데이터 + +> API 응답을 캐싱하였습니다. + +```json +// 응답 예시 +[ + { + "id": 0, + "title": "나 홀로 집에", + "description": "...", + "rating": "전체관람가", + "genre": "코미디", + "thumbnail": "https://...", + "runningTime": 103, + "releaseDate": "1991-07-06", + "theaters": ["강남점", "안양점"], + "showtimes": ["08:00 ~ 09:45", "10:00 ~ 11:45"] + } +] +``` + +### 분산락 +> Lease Time 길게 잡을수록 한 스레드가 lock을 오래 잡고 있어, 동시성 성능 테스트할때 fail 요청률이 90% 이상 나왔습니다. +> +> Wait Time 경우 좌석 예매 특성상 오래 기다린다고 예약이 되는건 아니라 Lease Time보다 낮게 잡았습니다. +- Lease Time - 2초 +- Wait Time - 1초 + +### 보고서 +- [캐싱 성능 테스트 보고서](https://gusty-football-62b.notion.site/17f81b29f03680718163fe0b7798383e) +- [분산락 테스트 보고서](https://gusty-football-62b.notion.site/18781b29f03680049de7db34240a6733) + +### jacoco 리포트 + +| movie-api | booking-api | application | infrastructure | domain | +|----------------------------| ----------- |----------------------------|----------------------------|----------------------------| + | ![j_m](etc/readme/j_m.png) | ![j_b](etc/readme/j_b.png) | ![j_a](etc/readme/j_a.png) | ![j_i](etc/readme/j_i.png) | ![j_d](etc/readme/j_d.png) | \ No newline at end of file diff --git a/application/.gitignore b/application/.gitignore new file mode 100644 index 000000000..c31ae9fd0 --- /dev/null +++ b/application/.gitignore @@ -0,0 +1,36 @@ +../.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ \ No newline at end of file diff --git a/application/build.gradle b/application/build.gradle new file mode 100644 index 000000000..4fa5b762a --- /dev/null +++ b/application/build.gradle @@ -0,0 +1,22 @@ +dependencies { + implementation project(':domain') + + testImplementation project(':infrastructure') +} + +bootJar { + enabled = false +} + +test { + useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + + reports { + html.required = true + } +} \ No newline at end of file diff --git a/application/src/main/java/com/example/app/booking/dto/CreateBookingCommand.java b/application/src/main/java/com/example/app/booking/dto/CreateBookingCommand.java new file mode 100644 index 000000000..d8a2eb2b4 --- /dev/null +++ b/application/src/main/java/com/example/app/booking/dto/CreateBookingCommand.java @@ -0,0 +1,49 @@ +package com.example.app.booking.dto; + +import com.example.app.booking.domain.Booking; +import com.example.app.movie.type.TheaterSeat; +import lombok.Builder; + +import java.time.LocalDate; +import java.util.Set; + +@Builder +public record CreateBookingCommand( + Long userId, + Long movieId, + Long showtimeId, + Long theaterId, + LocalDate bookingDate, + Set seats +){ + public SearchSeatCommand toSearchSeatCommand() { + return SearchSeatCommand.builder() + .movieId(movieId) + .showtimeId(showtimeId) + .theaterId(theaterId) + .bookingDate(bookingDate) + .seats(seats) + .build(); + } + + public SearchBookingCommand toSearchBookingCommand() { + return SearchBookingCommand.builder() + .userId(userId) + .movieId(movieId) + .showtimeId(showtimeId) + .theaterId(theaterId) + .bookingDate(bookingDate) + .build(); + } + + public Booking toBooking() { + return Booking.builder() + .userId(userId) + .movieId(movieId) + .showtimeId(showtimeId) + .theaterId(theaterId) + .bookingDate(bookingDate) + .totalSeats(seats.size()) + .build(); + } +} diff --git a/application/src/main/java/com/example/app/booking/dto/SearchBookingCommand.java b/application/src/main/java/com/example/app/booking/dto/SearchBookingCommand.java new file mode 100644 index 000000000..c95cd0456 --- /dev/null +++ b/application/src/main/java/com/example/app/booking/dto/SearchBookingCommand.java @@ -0,0 +1,15 @@ +package com.example.app.booking.dto; + +import lombok.Builder; + +import java.time.LocalDate; + +@Builder +public record SearchBookingCommand( + Long userId, + Long movieId, + Long showtimeId, + Long theaterId, + LocalDate bookingDate +){ +} diff --git a/application/src/main/java/com/example/app/booking/dto/SearchSeatCommand.java b/application/src/main/java/com/example/app/booking/dto/SearchSeatCommand.java new file mode 100644 index 000000000..a51fc611f --- /dev/null +++ b/application/src/main/java/com/example/app/booking/dto/SearchSeatCommand.java @@ -0,0 +1,18 @@ +package com.example.app.booking.dto; + +import com.example.app.movie.type.TheaterSeat; +import lombok.Builder; + +import java.time.LocalDate; +import java.util.Set; + +@Builder +public record SearchSeatCommand( + Long bookingId, + Long movieId, + Long showtimeId, + Long theaterId, + LocalDate bookingDate, + Set seats +) { +} diff --git a/application/src/main/java/com/example/app/booking/port/CreateBookingPort.java b/application/src/main/java/com/example/app/booking/port/CreateBookingPort.java new file mode 100644 index 000000000..18508b1c5 --- /dev/null +++ b/application/src/main/java/com/example/app/booking/port/CreateBookingPort.java @@ -0,0 +1,8 @@ +package com.example.app.booking.port; + +import com.example.app.booking.domain.Booking; +import com.example.app.booking.dto.CreateBookingCommand; + +public interface CreateBookingPort { + Booking saveBooking(CreateBookingCommand createBookingCommand); +} diff --git a/application/src/main/java/com/example/app/booking/port/LoadBookingPort.java b/application/src/main/java/com/example/app/booking/port/LoadBookingPort.java new file mode 100644 index 000000000..507ba4356 --- /dev/null +++ b/application/src/main/java/com/example/app/booking/port/LoadBookingPort.java @@ -0,0 +1,10 @@ +package com.example.app.booking.port; + +import com.example.app.booking.domain.Booking; +import com.example.app.booking.dto.SearchBookingCommand; + +import java.util.List; + +public interface LoadBookingPort { + List loadAllBookings(SearchBookingCommand searchBookingCommand); +} diff --git a/application/src/main/java/com/example/app/booking/port/LoadSeatPort.java b/application/src/main/java/com/example/app/booking/port/LoadSeatPort.java new file mode 100644 index 000000000..bf2e56c9f --- /dev/null +++ b/application/src/main/java/com/example/app/booking/port/LoadSeatPort.java @@ -0,0 +1,13 @@ +package com.example.app.booking.port; + +import com.example.app.booking.domain.Seat; +import com.example.app.booking.dto.SearchSeatCommand; + +import java.util.List; + +public interface LoadSeatPort { + + List loadAllSeats(SearchSeatCommand searchSeatCommand); + + List loadAllSeatsByBookingIds(List bookingIds); +} diff --git a/application/src/main/java/com/example/app/booking/port/UpdateSeatPort.java b/application/src/main/java/com/example/app/booking/port/UpdateSeatPort.java new file mode 100644 index 000000000..b500e4899 --- /dev/null +++ b/application/src/main/java/com/example/app/booking/port/UpdateSeatPort.java @@ -0,0 +1,10 @@ +package com.example.app.booking.port; + +import com.example.app.booking.domain.Seat; + +import java.util.List; + +public interface UpdateSeatPort { + + List updateAllSeats(List seatIds, Long bookingId); +} diff --git a/application/src/main/java/com/example/app/booking/service/CreateBookingService.java b/application/src/main/java/com/example/app/booking/service/CreateBookingService.java new file mode 100644 index 000000000..522805fc9 --- /dev/null +++ b/application/src/main/java/com/example/app/booking/service/CreateBookingService.java @@ -0,0 +1,90 @@ +package com.example.app.booking.service; + +import com.example.app.booking.domain.Booking; +import com.example.app.booking.domain.Seat; +import com.example.app.booking.dto.CreateBookingCommand; +import com.example.app.booking.port.CreateBookingPort; +import com.example.app.booking.port.LoadBookingPort; +import com.example.app.booking.port.LoadSeatPort; +import com.example.app.booking.port.UpdateSeatPort; +import com.example.app.common.exception.APIException; +import com.example.app.common.function.DistributedLockService; +import com.example.app.movie.type.TheaterSeat; +import com.example.app.booking.usecase.CreateBookingUseCase; +import org.springframework.transaction.annotation.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import static com.example.app.booking.exception.BookingErrorMessage.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CreateBookingService implements CreateBookingUseCase { + + private final Integer MAX_SEATS = 5; + + private final LoadSeatPort loadSeatPort; + private final UpdateSeatPort updateSeatPort; + private final LoadBookingPort loadBookingPort; + private final CreateBookingPort createBookingPort; + + private final DistributedLockService distributedLockService; + + @Override + @Transactional + public Booking createBooking(String lockKey, CreateBookingCommand createBookingCommand) { + // 유저 확인 + checkValidUser(createBookingCommand.userId()); + + // 연속된 row 체크 + TheaterSeat.checkSeatsInSequence(createBookingCommand.seats()); + + // 기존 예약 조회 + var existingBookingIds = loadBookingPort.loadAllBookings(createBookingCommand.toSearchBookingCommand()) + .stream() + .map(Booking::id) + .toList(); + + // 기존 예약의 자리 조회 + var existingSeats = loadSeatPort.loadAllSeatsByBookingIds(existingBookingIds); + + // 요청한 자리 + 이미 예약한 자리 = 5개 넘는지 체크 + checkLimitMaxSeats(createBookingCommand.seats().size() + existingSeats.size()); + + // booking 생성 + var booking = createBookingPort.saveBooking(createBookingCommand); + + return distributedLockService.executeWithLockAndReturn(() -> { + var requestSeats = loadSeatPort.loadAllSeats(createBookingCommand.toSearchSeatCommand()); + + // 요청한 자리 예약 가능 여부 체크 + Seat.checkSeatsAvailable(requestSeats); + + // 요청한 자리들 업데이트 + var requestSeatIds = requestSeats.stream().map(Seat::id).toList(); + updateSeatPort.updateAllSeats(requestSeatIds, booking.id()); + + return booking; + }, lockKey, 1L, 3L); + } + + private void checkLimitMaxSeats(final int totalSeat) { + if (totalSeat > MAX_SEATS) { + throw new APIException(OVER_MAX_LIMIT_SEATS); + } + } + + private void checkValidUser(final long userId) { + log.info(">>>>>> Checking userId : {}", userId); + /* pseudo code + * try { + * var user = userApi.getUser(userId); + * if (user == null) { throw new APIException(NOT_VALID_USER); } + * } catch (Exception e) { + * throw new APIException(SERVICE_NETWORK_ERROR); + * } + * */ + } +} diff --git a/application/src/main/java/com/example/app/booking/service/SendMessageService.java b/application/src/main/java/com/example/app/booking/service/SendMessageService.java new file mode 100644 index 000000000..6cd75ccd5 --- /dev/null +++ b/application/src/main/java/com/example/app/booking/service/SendMessageService.java @@ -0,0 +1,16 @@ +package com.example.app.booking.service; + +import com.example.app.booking.usecase.SendMessageUseCase; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class SendMessageService implements SendMessageUseCase { + + public void sendMessage(String message) throws InterruptedException { + log.info(">>>>> Sending message: {}", message); + Thread.sleep(500); + log.info(">>>>> Sent"); + } +} diff --git a/application/src/main/java/com/example/app/booking/usecase/CreateBookingUseCase.java b/application/src/main/java/com/example/app/booking/usecase/CreateBookingUseCase.java new file mode 100644 index 000000000..bed68cfa5 --- /dev/null +++ b/application/src/main/java/com/example/app/booking/usecase/CreateBookingUseCase.java @@ -0,0 +1,8 @@ +package com.example.app.booking.usecase; + +import com.example.app.booking.domain.Booking; +import com.example.app.booking.dto.CreateBookingCommand; + +public interface CreateBookingUseCase { + Booking createBooking(String lockKey, CreateBookingCommand createBookingCommand); +} diff --git a/application/src/main/java/com/example/app/booking/usecase/SendMessageUseCase.java b/application/src/main/java/com/example/app/booking/usecase/SendMessageUseCase.java new file mode 100644 index 000000000..35a9e3312 --- /dev/null +++ b/application/src/main/java/com/example/app/booking/usecase/SendMessageUseCase.java @@ -0,0 +1,5 @@ +package com.example.app.booking.usecase; + +public interface SendMessageUseCase { + void sendMessage(String message) throws InterruptedException; +} diff --git a/application/src/main/java/com/example/app/common/annotation/DistributedLock.java b/application/src/main/java/com/example/app/common/annotation/DistributedLock.java new file mode 100644 index 000000000..6ecb9f4a1 --- /dev/null +++ b/application/src/main/java/com/example/app/common/annotation/DistributedLock.java @@ -0,0 +1,16 @@ +package com.example.app.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import java.util.concurrent.TimeUnit; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface DistributedLock { + String key(); // 락 키 + TimeUnit timeUnit() default TimeUnit.SECONDS; + long leaseTime() default 3L; // 락이 자동으로 해제될 시간 (초) + long waitTime() default 2L; // 락을 얻기 위해 대기할 최대 시간 (초) +} \ No newline at end of file diff --git a/application/src/main/java/com/example/app/common/asepct/DistributedLockAspect.java b/application/src/main/java/com/example/app/common/asepct/DistributedLockAspect.java new file mode 100644 index 000000000..3268ba85c --- /dev/null +++ b/application/src/main/java/com/example/app/common/asepct/DistributedLockAspect.java @@ -0,0 +1,59 @@ +package com.example.app.common.asepct; + +import com.example.app.common.annotation.DistributedLock; +import com.example.app.common.exception.LockException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; + +import java.lang.reflect.Method; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Aspect +@Component +@RequiredArgsConstructor +public class DistributedLockAspect { + + private final RedissonClient redissonClient; + private final RedisLockTransaction redisLockTransaction; + + @Around("@annotation(com.example.app.common.annotation.DistributedLock)") + public Object doLock(final ProceedingJoinPoint joinPoint) throws Throwable { + // annotation 획득 + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + DistributedLock distributedLock = method.getAnnotation(DistributedLock.class); + + // annotation 설정 + String lockKey = distributedLock.key(); + long leaseTime = distributedLock.leaseTime(); + long waitTime = distributedLock.waitTime(); + TimeUnit timeUnit = distributedLock.timeUnit(); + + RLock rLock = redissonClient.getLock(lockKey); + + try { + boolean lockAcquired = rLock.tryLock(waitTime, leaseTime, timeUnit); + + if (!lockAcquired) { + log.error("락을 획득 실패"); + throw new LockException(); + } + + return redisLockTransaction.proceed(joinPoint); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } finally { + if (rLock.isLocked() && rLock.isHeldByCurrentThread()) { + rLock.unlock(); + } + } + } +} diff --git a/application/src/main/java/com/example/app/common/asepct/RedisLockTransaction.java b/application/src/main/java/com/example/app/common/asepct/RedisLockTransaction.java new file mode 100644 index 000000000..b5cb188cc --- /dev/null +++ b/application/src/main/java/com/example/app/common/asepct/RedisLockTransaction.java @@ -0,0 +1,22 @@ +package com.example.app.common.asepct; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.function.Supplier; + +@Component +public class RedisLockTransaction { + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public Object proceed(final ProceedingJoinPoint joinPoint) throws Throwable { + return joinPoint.proceed(); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public T execute(Supplier action) { + return action.get(); + } +} diff --git a/application/src/main/java/com/example/app/common/function/DistributedLockService.java b/application/src/main/java/com/example/app/common/function/DistributedLockService.java new file mode 100644 index 000000000..f06953558 --- /dev/null +++ b/application/src/main/java/com/example/app/common/function/DistributedLockService.java @@ -0,0 +1,42 @@ +package com.example.app.common.function; + +import com.example.app.common.asepct.RedisLockTransaction; +import com.example.app.common.exception.LockException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; + +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DistributedLockService { + + private final RedissonClient redissonClient; + private final RedisLockTransaction redisLockTransaction; + + public T executeWithLockAndReturn(Supplier action, String lockKey, long waitTime, long leaseTime) { + RLock rLock = redissonClient.getLock(lockKey); + + try { + boolean lockAcquired = rLock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS); + + if (!lockAcquired) { + log.error("락을 획득 실패"); + throw new LockException(); + } + + return redisLockTransaction.execute(action); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } finally { + if (rLock.isLocked() && rLock.isHeldByCurrentThread()) { + rLock.unlock(); + } + } + } +} diff --git a/application/src/main/java/com/example/app/movie/dto/SearchMovieCommand.java b/application/src/main/java/com/example/app/movie/dto/SearchMovieCommand.java new file mode 100644 index 000000000..89b8afa90 --- /dev/null +++ b/application/src/main/java/com/example/app/movie/dto/SearchMovieCommand.java @@ -0,0 +1,11 @@ +package com.example.app.movie.dto; + +import com.example.app.movie.type.MovieGenre; +import lombok.Builder; + +@Builder +public record SearchMovieCommand( + String title, + MovieGenre genre +) { +} diff --git a/application/src/main/java/com/example/app/movie/port/LoadMoviePort.java b/application/src/main/java/com/example/app/movie/port/LoadMoviePort.java new file mode 100644 index 000000000..1d9699d75 --- /dev/null +++ b/application/src/main/java/com/example/app/movie/port/LoadMoviePort.java @@ -0,0 +1,10 @@ +package com.example.app.movie.port; + +import com.example.app.movie.domain.Movie; +import com.example.app.movie.dto.SearchMovieCommand; + +import java.util.List; + +public interface LoadMoviePort { + List loadAllMovies(SearchMovieCommand searchMovieCommand); +} diff --git a/application/src/main/java/com/example/app/movie/service/SearchMovieService.java b/application/src/main/java/com/example/app/movie/service/SearchMovieService.java new file mode 100644 index 000000000..2492ae229 --- /dev/null +++ b/application/src/main/java/com/example/app/movie/service/SearchMovieService.java @@ -0,0 +1,26 @@ +package com.example.app.movie.service; + +import com.example.app.movie.domain.Movie; +import com.example.app.movie.dto.SearchMovieCommand; +import com.example.app.movie.port.LoadMoviePort; +import com.example.app.movie.usecase.SearchMovieUseCase; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SearchMovieService implements SearchMovieUseCase { + + private final LoadMoviePort loadMoviePort; + + @Override + @Cacheable(value = "movies", key = "#searchMovieCommand.title + ':' + #searchMovieCommand.genre") + public List searchMovies(SearchMovieCommand searchMovieCommand) { + return loadMoviePort.loadAllMovies(searchMovieCommand); + } +} diff --git a/application/src/main/java/com/example/app/movie/usecase/SearchMovieUseCase.java b/application/src/main/java/com/example/app/movie/usecase/SearchMovieUseCase.java new file mode 100644 index 000000000..b6b560284 --- /dev/null +++ b/application/src/main/java/com/example/app/movie/usecase/SearchMovieUseCase.java @@ -0,0 +1,10 @@ +package com.example.app.movie.usecase; + +import com.example.app.movie.domain.Movie; +import com.example.app.movie.dto.SearchMovieCommand; + +import java.util.List; + +public interface SearchMovieUseCase { + List searchMovies(SearchMovieCommand searchMovieCommand); +} diff --git a/application/src/test/java/com/example/TestApplication.java b/application/src/test/java/com/example/TestApplication.java new file mode 100644 index 000000000..e0ed55e30 --- /dev/null +++ b/application/src/test/java/com/example/TestApplication.java @@ -0,0 +1,9 @@ +package com.example; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@SpringBootApplication +@EnableJpaAuditing +public class TestApplication { +} diff --git a/application/src/test/java/com/example/app/booking/service/CreateBookingServiceTest.java b/application/src/test/java/com/example/app/booking/service/CreateBookingServiceTest.java new file mode 100644 index 000000000..1aed96998 --- /dev/null +++ b/application/src/test/java/com/example/app/booking/service/CreateBookingServiceTest.java @@ -0,0 +1,110 @@ +package com.example.app.booking.service; + +import com.example.app.booking.domain.Booking; +import com.example.app.booking.dto.CreateBookingCommand; +import com.example.app.booking.out.persistence.adapter.BookingPersistenceAdapter; +import com.example.app.booking.out.persistence.adapter.SeatPersistenceAdapter; +import com.example.app.common.exception.APIException; +import com.example.app.common.function.DistributedLockService; +import com.example.app.config.EmbeddedRedisConfig; +import com.example.app.movie.type.TheaterSeat; +import com.navercorp.fixturemonkey.FixtureMonkey; +import com.navercorp.fixturemonkey.api.introspector.ConstructorPropertiesArbitraryIntrospector; +import com.navercorp.fixturemonkey.jakarta.validation.plugin.JakartaValidationPlugin; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; +import java.util.Set; +import java.util.function.Supplier; + +import static com.example.app.booking.exception.BookingErrorMessage.SEAT_ROW_NOT_IN_SEQUENCE; +import static com.navercorp.fixturemonkey.api.instantiator.Instantiator.constructor; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@SpringBootTest(classes = EmbeddedRedisConfig.class) +@TestPropertySource(properties = "spring.config.location = classpath:application-test.yml") +public class CreateBookingServiceTest { + + private FixtureMonkey fixtureMonkey; + + @Mock + private DistributedLockService distributedLockService; + + @Mock + private BookingPersistenceAdapter bookingPersistenceAdapter; + + @Mock + private SeatPersistenceAdapter seatPersistenceAdapter; + + @InjectMocks + private CreateBookingService sut; + + @BeforeEach + void setUp() { + fixtureMonkey = FixtureMonkey.builder() + .objectIntrospector(ConstructorPropertiesArbitraryIntrospector.INSTANCE) + .plugin(new JakartaValidationPlugin()) + .build(); + } + + @Test + public void 예약_테스트() { + var key = fixtureMonkey.giveMeOne(String.class); + var continuousSeats = Set.of(TheaterSeat.A3, TheaterSeat.A4, TheaterSeat.A5); + var bookingCommand = fixtureMonkey.giveMeBuilder(CreateBookingCommand.class) + .instantiate(constructor() + .parameter(long.class) + .parameter(long.class) + .parameter(long.class) + .parameter(long.class) + .parameter(LocalDate.class) + .parameter(Set.class, "seats")) + .set("seats", continuousSeats) + .sample(); + + var booking = fixtureMonkey.giveMeBuilder(Booking.class) + .instantiate(constructor() + .parameter(long.class) + .parameter(long.class) + .parameter(long.class) + .parameter(long.class) + .parameter(long.class) + .parameter(int.class, "totalSeats") + .parameter(LocalDate.class)) + .set("totalSeats", 3) + .sample(); + + when(distributedLockService.executeWithLockAndReturn(any(Supplier.class), any(String.class), any(Long.class), any(Long.class))) + .thenReturn(booking); + + var result = sut.createBooking(key, bookingCommand); + + assertEquals(booking, result); + } + + @Test + public void 예약_불가_테스트() { + var key = fixtureMonkey.giveMeOne(String.class); + var discontinuousSeats = Set.of(TheaterSeat.B1, TheaterSeat.C1, TheaterSeat.D1); + var bookingCommand = fixtureMonkey.giveMeBuilder(CreateBookingCommand.class) + .instantiate(constructor() + .parameter(long.class) + .parameter(long.class) + .parameter(long.class) + .parameter(long.class) + .parameter(LocalDate.class) + .parameter(Set.class, "seats")) + .set("seats", discontinuousSeats) + .sample(); + + var exception = assertThrows(APIException.class, () -> sut.createBooking(key, bookingCommand)); + assertEquals(SEAT_ROW_NOT_IN_SEQUENCE.getMessage(), exception.getMessage()); + } +} diff --git a/application/src/test/java/com/example/app/config/EmbeddedRedisConfig.java b/application/src/test/java/com/example/app/config/EmbeddedRedisConfig.java new file mode 100644 index 000000000..8c80142a9 --- /dev/null +++ b/application/src/test/java/com/example/app/config/EmbeddedRedisConfig.java @@ -0,0 +1,29 @@ +package com.example.app.config; + +import jakarta.annotation.PreDestroy; +import jakarta.annotation.PostConstruct; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +import org.springframework.boot.test.context.TestConfiguration; +import redis.embedded.RedisServer; + +import java.io.IOException; + +@TestConfiguration +public class EmbeddedRedisConfig { + + private final RedisServer redisServer; + + public EmbeddedRedisConfig(RedisProperties redisProperties) throws IOException { + this.redisServer = new RedisServer(redisProperties.getPort()); + } + + @PostConstruct + public void postConstruct() throws IOException { + redisServer.start(); + } + + @PreDestroy + public void preDestroy() throws IOException { + redisServer.stop(); + } +} \ No newline at end of file diff --git a/application/src/test/java/com/example/app/movie/service/SearchMovieServiceTest.java b/application/src/test/java/com/example/app/movie/service/SearchMovieServiceTest.java new file mode 100644 index 000000000..e86d3eb3d --- /dev/null +++ b/application/src/test/java/com/example/app/movie/service/SearchMovieServiceTest.java @@ -0,0 +1,82 @@ +package com.example.app.movie.service; + +import com.example.app.config.EmbeddedRedisConfig; +import com.example.app.movie.domain.Movie; +import com.example.app.movie.domain.Showtime; +import com.example.app.movie.domain.Theater; +import com.example.app.movie.dto.SearchMovieCommand; +import com.example.app.movie.out.persistence.adapter.MoviePersistenceAdapter; +import com.example.app.movie.type.MovieGenre; +import com.example.app.movie.type.MovieRating; +import com.example.app.movie.type.MovieStatus; +import com.navercorp.fixturemonkey.FixtureMonkey; +import com.navercorp.fixturemonkey.api.introspector.ConstructorPropertiesArbitraryIntrospector; +import com.navercorp.fixturemonkey.api.type.TypeReference; +import com.navercorp.fixturemonkey.jakarta.validation.plugin.JakartaValidationPlugin; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.time.LocalDate; +import java.util.List; + +import static com.navercorp.fixturemonkey.api.instantiator.Instantiator.constructor; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@SpringBootTest(classes = EmbeddedRedisConfig.class) +@TestPropertySource(properties = "spring.config.location = classpath:application-test.yml") +public class SearchMovieServiceTest { + + private final int TOTAL_MOVIES = 10; + + private FixtureMonkey fixtureMonkey; + + @Mock + private MoviePersistenceAdapter moviePersistenceAdapter; + + @InjectMocks + private SearchMovieService sut; + + @BeforeEach + void setUp() { + fixtureMonkey = FixtureMonkey.builder() + .objectIntrospector(ConstructorPropertiesArbitraryIntrospector.INSTANCE) + .plugin(new JakartaValidationPlugin()) + .build(); + } + + @Test + public void 영화_검색() { + var searchCommand = fixtureMonkey.giveMeBuilder(SearchMovieCommand.class) + .instantiate(constructor() + .parameter(String.class) + .parameter(MovieGenre.class)) + .sample(); + + var movies = fixtureMonkey.giveMeBuilder(Movie.class) + .instantiate(constructor() + .parameter(long.class) + .parameter(String.class) + .parameter(String.class) + .parameter(MovieStatus.class) + .parameter(MovieRating.class) + .parameter(MovieGenre.class) + .parameter(String.class) + .parameter(int.class) + .parameter(LocalDate.class) + .parameter(new TypeReference>(){}) + .parameter(new TypeReference>(){})) + .sampleList(TOTAL_MOVIES); + + when(moviePersistenceAdapter.loadAllMovies(any(SearchMovieCommand.class))).thenReturn(movies); + + var result = sut.searchMovies(searchCommand); + + assertEquals(TOTAL_MOVIES, result.size()); + } +} diff --git a/application/src/test/resources/application-test.yml b/application/src/test/resources/application-test.yml new file mode 100644 index 000000000..77349edd9 --- /dev/null +++ b/application/src/test/resources/application-test.yml @@ -0,0 +1,15 @@ +spring: + datasource : + url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driverClassName: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect + show-sql: true + data: + redis: + host: localhost + port: 6379 \ No newline at end of file diff --git a/application/src/test/resources/movie-data.sql b/application/src/test/resources/movie-data.sql new file mode 100644 index 000000000..fb8112d39 --- /dev/null +++ b/application/src/test/resources/movie-data.sql @@ -0,0 +1,36 @@ +INSERT INTO `tb_movie` (`movie_id`,`title`,`description`,`status`,`rating`,`genre`,`thumbnail`,`running_time`,`release_date`,`updated_at`,`created_at`) VALUES (1,'나 홀로 집에','크리스마스 휴가 당일, 늦잠을 자 정신없이 공항으로 출발한 맥콜리스터 가족은 전날 부린 말썽에 대한 벌로 다락방에 들어가 있던 8살 케빈을 깜박 잊고 프랑스로 떠나버린다. 매일 형제들에게 치이며 가족이 전부 없어졌으면 좋겠다고 생각한 케빈은 갑자기 찾아온 자유를 만끽한다.','SHOWING','ALL_AGES','COMEDY','https://m.media-amazon.com/images/M/MV5BNzNmNmQ2ZDEtMTc1MS00NjNiLThlMGUtZmQxNTg1Nzg5NWMzXkEyXkFqcGc@._V1_QL75_UX190_CR0,1,190,281_.jpg',103,'1991-07-06','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie` (`movie_id`,`title`,`description`,`status`,`rating`,`genre`,`thumbnail`,`running_time`,`release_date`,`updated_at`,`created_at`) VALUES (2,'탑건','최고의 파일럿들만이 갈 수 있는 캘리포니아의 한 비행 조종 학교 탑건에서의 사나이들의 우정과 사랑의 모험이 시작된다. 자신을 좇는 과거의 기억과 경쟁자, 그리고 사랑 사이에서 고군분투하는 그의 여정이 펼쳐진다.','SHOWING','FIFTEEN_ABOVE','ACTION','https://m.media-amazon.com/images/M/MV5BZmVjNzQ3MjYtYTZiNC00Y2YzLWExZTEtMTM2ZDllNDI0MzgyXkEyXkFqcGc@._V1_QL75_UX190_CR0,6,190,281_.jpg',109,'1986-05-12','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie` (`movie_id`,`title`,`description`,`status`,`rating`,`genre`,`thumbnail`,`running_time`,`release_date`,`updated_at`,`created_at`) VALUES (3,'탑건:메버릭','해군 최고의 비행사 피트 미첼은 비행 훈련소에서 갓 졸업을 한 신입 비행사들 팀의 훈련을 맡게 된다. 자신을 좇는 과거의 기억과 위험천만한 임무 속에서 고군분투하는 그의 비상이 펼쳐진다.','SHOWING','TWELVE_ABOVE','ACTION','https://m.media-amazon.com/images/M/MV5BMDBkZDNjMWEtOTdmMi00NmExLTg5MmMtNTFlYTJlNWY5YTdmXkEyXkFqcGc@._V1_QL75_UX190_CR0,0,190,281_.jpg',130,'2022-05-18','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie` (`movie_id`,`title`,`description`,`status`,`rating`,`genre`,`thumbnail`,`running_time`,`release_date`,`updated_at`,`created_at`) VALUES (4,'하얼빈','1908년 함경북도 신아산에서 안중근이 이끄는 독립군들은 일본군과의 전투에서 큰 승리를 거둔다. 대한의군 참모중장 안중근은 만국공법에 따라 전쟁포로인 일본인들을 풀어주게 되고, 이 사건으로 인해 독립군 사이에서는 안중근에 대한 의심과 함께 균열이 일기 시작한다.','SCHEDULED','FIFTEEN_ABOVE','ACTION','https://m.media-amazon.com/images/M/MV5BNmY4YzM5NzUtMTg4Yy00Yzc3LThlZDktODk4YjljZmNlODA0XkEyXkFqcGc@._V1_QL75_UY281_CR4,0,190,281_.jpg',113,'2024-12-24','2025-01-09 00:00:00','2025-01-09 00:00:00'); + +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (1,1,'08:00:00','09:45:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (2,1,'10:00:00','11:45:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (3,1,'13:00:00','14:45:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (4,1,'15:30:00','17:15:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (5,2,'10:30:00','12:15:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (6,2,'14:30:00','16:15:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (7,3,'11:30:00','14:15:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (8,3,'15:40:00','17:25:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (9,3,'18:50:00','20:45:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (10,3,'07:30:00','09:50:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (11,4,'11:10:00','13:05:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); + +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (1,'강남점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (2,'강북점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (3,'봉천점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (4,'안양점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (5,'평촌점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (6,'인덕원점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (7,'사당점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (8,'삼성점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (9,'신림점','2025-01-09 00:00:00','2025-01-09 00:00:00'); + +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (1,1,1,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (2,2,3,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (3,3,4,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (4,1,4,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (5,1,5,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (6,2,5,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (7,2,1,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (8,3,2,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (9,4,8,'2025-01-09 00:00:00','2025-01-09 00:00:00'); \ No newline at end of file diff --git a/application/src/test/resources/seat-data.sql b/application/src/test/resources/seat-data.sql new file mode 100644 index 000000000..dda6d8b12 --- /dev/null +++ b/application/src/test/resources/seat-data.sql @@ -0,0 +1,25 @@ +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','A1',0,0,'2025-01-25 18:11:53','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','A2',0,0,'2025-01-25 18:11:53','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','A3',0,0,'2025-01-25 18:11:53','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','B1',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','B2',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','A4',0,0,'2025-01-26 00:45:45','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','B3',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','A5',0,0,'2025-01-26 00:45:45','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','B4',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','B5',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','C1',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','C2',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','C3',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','C4',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','C5',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','D1',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','D2',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','D3',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','D4',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','D5',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','E1',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','E2',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','E3',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','E4',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','E5',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); diff --git a/booking-api/.gitignore b/booking-api/.gitignore new file mode 100644 index 000000000..c31ae9fd0 --- /dev/null +++ b/booking-api/.gitignore @@ -0,0 +1,36 @@ +../.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ \ No newline at end of file diff --git a/booking-api/build.gradle b/booking-api/build.gradle new file mode 100644 index 000000000..1f169b1af --- /dev/null +++ b/booking-api/build.gradle @@ -0,0 +1,36 @@ +dependencies { + implementation project(':domain') + implementation project(':infrastructure') + implementation project(':application') +} + +bootJar { + enabled = true +} + +test { + useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + + reports { + html.required = true + } +} + +jacocoTestCoverageVerification { + dependsOn jacocoTestReport + + violationRules { + rule { + element = 'PACKAGE' + includes = ['com.example.app.booking.presentation.controller.*'] + limit { + maximum = 0.80 + } + } + } +} \ No newline at end of file diff --git a/booking-api/src/main/java/com/example/BookingApiApplication.java b/booking-api/src/main/java/com/example/BookingApiApplication.java new file mode 100644 index 000000000..42570b8f0 --- /dev/null +++ b/booking-api/src/main/java/com/example/BookingApiApplication.java @@ -0,0 +1,14 @@ +package com.example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@EnableJpaAuditing +@SpringBootApplication +public class BookingApiApplication { + + public static void main(String[] args) { + SpringApplication.run(BookingApiApplication.class, args); + } +} diff --git a/booking-api/src/main/java/com/example/app/booking/presentation/controller/BookingController.java b/booking-api/src/main/java/com/example/app/booking/presentation/controller/BookingController.java new file mode 100644 index 000000000..d9d877cfc --- /dev/null +++ b/booking-api/src/main/java/com/example/app/booking/presentation/controller/BookingController.java @@ -0,0 +1,40 @@ +package com.example.app.booking.presentation.controller; + +import com.example.app.booking.domain.Booking; +import com.example.app.booking.presentation.dto.request.CreateBookingRequest; +import com.example.app.booking.presentation.service.RedisRateLimitService; +import com.example.app.booking.presentation.util.BookingKeyGenerator; +import com.example.app.booking.usecase.CreateBookingUseCase; +import com.example.app.booking.usecase.SendMessageUseCase; +import jakarta.validation.Valid; +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; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1") +public class BookingController { + + private final CreateBookingUseCase createBookingUseCase; + private final SendMessageUseCase sendMessageUseCase; + private final RedisRateLimitService redisRateLimitService; + + @PostMapping("/booking") + public ResponseEntity createBooking(@Valid @RequestBody CreateBookingRequest createBookingRequest) + throws InterruptedException { + redisRateLimitService.checkAccessLimit(BookingKeyGenerator.generateRateLimitKey(createBookingRequest)); + + var lockKey = BookingKeyGenerator.generateLockKey(createBookingRequest); + var booking = createBookingUseCase.createBooking(lockKey, createBookingRequest.toCreateBookingCommand()); + sendMessageUseCase.sendMessage(String.format("BookingId : %d, UserId : %d", booking.id(), booking.userId())); + + redisRateLimitService.setAccessLimit(BookingKeyGenerator.generateRateLimitKey(createBookingRequest)); + + return ResponseEntity.status(HttpStatus.CREATED).build(); + } +} diff --git a/booking-api/src/main/java/com/example/app/booking/presentation/dto/request/CreateBookingRequest.java b/booking-api/src/main/java/com/example/app/booking/presentation/dto/request/CreateBookingRequest.java new file mode 100644 index 000000000..754799e62 --- /dev/null +++ b/booking-api/src/main/java/com/example/app/booking/presentation/dto/request/CreateBookingRequest.java @@ -0,0 +1,43 @@ +package com.example.app.booking.presentation.dto.request; + +import com.example.app.booking.dto.CreateBookingCommand; +import com.example.app.movie.type.TheaterSeat; +import com.example.app.common.annotation.ValidEnums; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; + +public record CreateBookingRequest( + @Positive(message = "userId는 0보다 커요") + Long userId, + @Positive(message = "movieId는 0보다 커요") + Long movieId, + @Positive(message = "showtimeId는 0보다 커요") + Long showtimeId, + @Positive(message = "theaterId는 0보다 커요") + Long theaterId, + @FutureOrPresent + @DateTimeFormat(pattern = "yyyy-MM-dd") + LocalDate bookingDate, + @NotNull + @ValidEnums(enumClass = TheaterSeat.class) + @Size(min = 1, max = 5, message = "좌석은 최소 1개, 최대 5개 예매 가능해요") + List seats +) { + public CreateBookingCommand toCreateBookingCommand() { + return CreateBookingCommand.builder() + .userId(userId) + .movieId(movieId) + .showtimeId(showtimeId) + .theaterId(theaterId) + .bookingDate(bookingDate) + .seats(seats.stream().map(TheaterSeat::valueOf).collect(Collectors.toSet())) + .build(); + } +} diff --git a/booking-api/src/main/java/com/example/app/booking/presentation/service/RateLimitService.java b/booking-api/src/main/java/com/example/app/booking/presentation/service/RateLimitService.java new file mode 100644 index 000000000..e35efe82e --- /dev/null +++ b/booking-api/src/main/java/com/example/app/booking/presentation/service/RateLimitService.java @@ -0,0 +1,37 @@ +package com.example.app.booking.presentation.service; + +import com.example.app.booking.presentation.dto.request.CreateBookingRequest; +import com.example.app.common.exception.RateLimitException; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.google.common.util.concurrent.RateLimiter; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.format.DateTimeFormatter; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +@SuppressWarnings("UnstableApiUsage") +@Service +@RequiredArgsConstructor +public class RateLimitService { + private final Cache rateLimiters = CacheBuilder.newBuilder() + .expireAfterAccess(5, TimeUnit.MINUTES) + .build(); + + public void checkAccessLimit(CreateBookingRequest createBookingRequest) throws ExecutionException { + var key = String.format("%d:%d:%d:%d:%s", + createBookingRequest.userId(), + createBookingRequest.movieId(), + createBookingRequest.showtimeId(), + createBookingRequest.theaterId(), + createBookingRequest.bookingDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))); + + RateLimiter rateLimiter = rateLimiters.get(key, () -> RateLimiter.create(1.0/300.0)); + + if (!rateLimiter.tryAcquire()) { + throw new RateLimitException(); + } + } +} diff --git a/booking-api/src/main/java/com/example/app/booking/presentation/service/RedisRateLimitService.java b/booking-api/src/main/java/com/example/app/booking/presentation/service/RedisRateLimitService.java new file mode 100644 index 000000000..83c289f17 --- /dev/null +++ b/booking-api/src/main/java/com/example/app/booking/presentation/service/RedisRateLimitService.java @@ -0,0 +1,58 @@ +package com.example.app.booking.presentation.service; + +import com.example.app.common.exception.RateLimitException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Service; + +import java.util.Collections; + +@Service +@RequiredArgsConstructor +public class RedisRateLimitService { + + private final RedisTemplate redisTemplate; + + public void checkAccessLimit(String key) { + Boolean allowed = redisTemplate.execute(new DefaultRedisScript() { + { + setScriptText(""" + local key = KEYS[1] + local now = redis.call('TIME') + local timestamp = tonumber(now[1]) + + if redis.call('GET', key) then + return 0 + end + + return 1 + """); + setResultType(Boolean.class); + } + }, Collections.singletonList(key)); + + if (Boolean.FALSE.equals(allowed)) { + throw new RateLimitException(); + } + } + + public void setAccessLimit(String key) { + redisTemplate.execute(new DefaultRedisScript() { + { + setScriptText(""" + local key = KEYS[1] + local now = redis.call('TIME') + local timestamp = tonumber(now[1]) + + if redis.call('GET', key) then + return + end + + redis.call('SET', key, timestamp, 'EX', 300) -- 5분 만료 + """); + setResultType(Boolean.class); + } + }, Collections.singletonList(key)); + } +} diff --git a/booking-api/src/main/java/com/example/app/booking/presentation/util/BookingKeyGenerator.java b/booking-api/src/main/java/com/example/app/booking/presentation/util/BookingKeyGenerator.java new file mode 100644 index 000000000..e203fe563 --- /dev/null +++ b/booking-api/src/main/java/com/example/app/booking/presentation/util/BookingKeyGenerator.java @@ -0,0 +1,28 @@ +package com.example.app.booking.presentation.util; + +import com.example.app.booking.presentation.dto.request.CreateBookingRequest; + +import java.time.format.DateTimeFormatter; + +public class BookingKeyGenerator { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + public static String generateLockKey(CreateBookingRequest createBookingRequest) { + return String.format("BOOKING:%d:%d:%d:%s:%c", + createBookingRequest.movieId(), + createBookingRequest.showtimeId(), + createBookingRequest.theaterId(), + createBookingRequest.bookingDate().format(DATE_FORMATTER), + createBookingRequest.seats().getFirst().charAt(0)); + } + + public static String generateRateLimitKey(CreateBookingRequest createBookingRequest) { + return String.format("BOOKING:RATE_LIMIT:%d:%d:%d:%d:%s", + createBookingRequest.userId(), + createBookingRequest.movieId(), + createBookingRequest.showtimeId(), + createBookingRequest.theaterId(), + createBookingRequest.bookingDate().format(DATE_FORMATTER)); + } +} diff --git a/booking-api/src/main/resources/application.yml b/booking-api/src/main/resources/application.yml new file mode 100644 index 000000000..51f3a90e4 --- /dev/null +++ b/booking-api/src/main/resources/application.yml @@ -0,0 +1,23 @@ +spring: + application: + name: api + datasource: + url: jdbc:mysql://localhost:3306/my?useUnicode=true&characterEncoding=utf8&autoReconnect=true&serverTimezone=Asia/Seoul + username: root + password: 1234 + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.MySQLDialect + data: + redis: + host: localhost + port: 6379 + +server: + port: 8090 + servlet: + context-path: /api \ No newline at end of file diff --git a/booking-api/src/test/java/com/example/BookingApiApplicationTests.java b/booking-api/src/test/java/com/example/BookingApiApplicationTests.java new file mode 100644 index 000000000..327e200ca --- /dev/null +++ b/booking-api/src/test/java/com/example/BookingApiApplicationTests.java @@ -0,0 +1,7 @@ +package com.example; + +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class BookingApiApplicationTests { +} diff --git a/booking-api/src/test/java/com/example/app/booking/presentation/config/EmbeddedRedisConfig.java b/booking-api/src/test/java/com/example/app/booking/presentation/config/EmbeddedRedisConfig.java new file mode 100644 index 000000000..ddeb08b0b --- /dev/null +++ b/booking-api/src/test/java/com/example/app/booking/presentation/config/EmbeddedRedisConfig.java @@ -0,0 +1,29 @@ +package com.example.app.booking.presentation.config; + +import jakarta.annotation.PreDestroy; +import jakarta.annotation.PostConstruct; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +import org.springframework.boot.test.context.TestConfiguration; +import redis.embedded.RedisServer; + +import java.io.IOException; + +@TestConfiguration +public class EmbeddedRedisConfig { + + private final RedisServer redisServer; + + public EmbeddedRedisConfig(RedisProperties redisProperties) throws IOException { + this.redisServer = new RedisServer(redisProperties.getPort()); + } + + @PostConstruct + public void postConstruct() throws IOException { + redisServer.start(); + } + + @PreDestroy + public void preDestroy() throws IOException { + redisServer.stop(); + } +} \ No newline at end of file diff --git a/booking-api/src/test/java/com/example/app/booking/presentation/controller/BookingControllerTest.java b/booking-api/src/test/java/com/example/app/booking/presentation/controller/BookingControllerTest.java new file mode 100644 index 000000000..c8046765e --- /dev/null +++ b/booking-api/src/test/java/com/example/app/booking/presentation/controller/BookingControllerTest.java @@ -0,0 +1,97 @@ +package com.example.app.booking.presentation.controller; + +import com.example.app.booking.presentation.config.EmbeddedRedisConfig; +import com.example.app.booking.presentation.dto.request.CreateBookingRequest; +import com.example.app.common.exception.APIException; +import com.example.app.common.exception.LockException; +import com.example.app.common.exception.RateLimitException; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.jdbc.Sql; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static com.example.app.booking.exception.BookingErrorMessage.SEAT_ROW_NOT_IN_SEQUENCE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest(classes = EmbeddedRedisConfig.class) +@TestPropertySource(properties = "spring.config.location = classpath:application-test.yml") +@Sql(scripts = "/booking-data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +public class BookingControllerTest { + + private final int TOTAL_BOOKINGS = 10; + private final int SUCCESS_BOOKING = 1; + private final int FAIL_BOOKINGS = 9; + + @Autowired + private BookingController sut; + + @Test + public void 영화_예약_성공_테스트() throws InterruptedException { + var bookingRequest = new CreateBookingRequest(1L, 2L, 5L, 1L, LocalDate.of(2025, 3, 1), List.of("A1", "A2")); + var response = sut.createBooking(bookingRequest); + assertEquals(201, response.getStatusCode().value()); + } + + @Test + public void Rate_Limit_유저_영화_시간표_상영관_per_1요청_5분_테스트() { + var bookingRequest = new CreateBookingRequest(1L, 2L, 5L, 1L, LocalDate.of(2025, 3, 1), List.of("C1", "C2")); + assertThrows(RateLimitException.class, () -> sut.createBooking(bookingRequest)); + } + + @Test + public void 영화_예약_실패_테스트() { + var bookingRequest = new CreateBookingRequest(2L, 2L, 5L, 1L, LocalDate.of(2025, 3, 1), List.of("A1", "B1")); + var exception = assertThrows(APIException.class, () -> sut.createBooking(bookingRequest)); + assertEquals(SEAT_ROW_NOT_IN_SEQUENCE.getMessage(), exception.getMessage()); + } + + @Test + public void 동시성_예약_테스트() throws InterruptedException { + List bookingRequests = new ArrayList<>(); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger exceptionCount = new AtomicInteger(0); + + + for (int i=0; i < TOTAL_BOOKINGS; i++) { + bookingRequests.add(new CreateBookingRequest((long) i+5, 2L, 5L, 1L, LocalDate.of(2025, 3, 1), List.of("E1", "E2"))); + } + + int threadCount = bookingRequests.size(); + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); // pool 생성 + CountDownLatch latch = new CountDownLatch(threadCount); // 쓰레드 작업 카운트 + + for (int i = 0; i < threadCount; i++) { + final int taskId = i; + + executor.execute(() -> { + try { + sut.createBooking(bookingRequests.get(taskId)); + successCount.incrementAndGet(); + } catch (LockException | APIException e) { + exceptionCount.incrementAndGet(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); // 카운트 0까지 기다림 + executor.shutdown(); // pool 종료 + + assertEquals(SUCCESS_BOOKING, successCount.get()); + assertEquals(FAIL_BOOKINGS, exceptionCount.get()); + } +} diff --git a/booking-api/src/test/resources/application-test.yml b/booking-api/src/test/resources/application-test.yml new file mode 100644 index 000000000..77349edd9 --- /dev/null +++ b/booking-api/src/test/resources/application-test.yml @@ -0,0 +1,15 @@ +spring: + datasource : + url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driverClassName: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect + show-sql: true + data: + redis: + host: localhost + port: 6379 \ No newline at end of file diff --git a/booking-api/src/test/resources/booking-data.sql b/booking-api/src/test/resources/booking-data.sql new file mode 100644 index 000000000..bfc22135b --- /dev/null +++ b/booking-api/src/test/resources/booking-data.sql @@ -0,0 +1,25 @@ +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','A1',0,0,'2025-01-25 18:11:53','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','A2',0,0,'2025-01-25 18:11:53','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','A3',0,0,'2025-01-25 18:11:53','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','B1',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','B2',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','A4',0,0,'2025-01-26 00:45:45','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','B3',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','A5',0,0,'2025-01-26 00:45:45','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','B4',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','B5',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','C1',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','C2',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','C3',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','C4',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','C5',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','D1',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','D2',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','D3',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','D4',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','D5',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','E1',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','E2',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','E3',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','E4',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); +INSERT INTO `tb_seat` (`movie_id`,`theater_id`,`showtime_id`,`booking_date`,`seat`,`reserved`,`version`,`updated_at`,`created_at`) VALUES (2,1,5,'2025-03-01','E5',0,0,'2025-01-25 00:00:00','2025-01-25 00:00:00'); \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..d20bb2929 --- /dev/null +++ b/build.gradle @@ -0,0 +1,62 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.3.2' + id 'io.spring.dependency-management' version '1.1.6' + id 'jacoco' +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +allprojects { + group = 'com.example' + version = '0.0.1-SNAPSHOT' + + repositories { + mavenCentral() + } + + jacoco { + toolVersion = '0.8.5' + } + + tasks.withType(Test) { + jacoco { + enabled = true + } + } +} + +subprojects { + apply plugin: 'java' + apply plugin: 'org.springframework.boot' + apply plugin: 'io.spring.dependency-management' + apply plugin: 'jacoco' + + dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + + implementation 'org.springframework.boot:spring-boot-starter-cache' + implementation 'com.github.ben-manes.caffeine:caffeine' + implementation 'org.redisson:redisson-spring-boot-starter:3.41.0' + + implementation 'com.google.guava:guava:33.4.0-jre' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + developmentOnly 'org.springframework.boot:spring-boot-devtools' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'com.navercorp.fixturemonkey:fixture-monkey-starter:1.1.8' + testImplementation 'com.github.codemonstur:embedded-redis:1.0.0' // MacOS Sonoma + // testImplementation 'it.ozimov:embedded-redis:0.7.2' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testRuntimeOnly 'com.h2database:h2' + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..2f20784f7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +version: '3.8' +services: + mysql: + image: mysql:8.0.37 + container_name: movie-db + environment: + MYSQL_ROOT_PASSWORD: 1234 + ports: + - "3306:3306" + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --skip-character-set-client-handshake + volumes: + - ./etc/database:/docker-entrypoint-initdb.d + redis: + image: redis:7.4.2 + container_name: movie-redis + ports: + - "6379:6379" \ No newline at end of file diff --git a/domain/.gitignore b/domain/.gitignore new file mode 100644 index 000000000..c31ae9fd0 --- /dev/null +++ b/domain/.gitignore @@ -0,0 +1,36 @@ +../.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ \ No newline at end of file diff --git a/domain/build.gradle b/domain/build.gradle new file mode 100644 index 000000000..af7ac9deb --- /dev/null +++ b/domain/build.gradle @@ -0,0 +1,20 @@ +dependencies { + implementation 'com.fasterxml.jackson.core:jackson-annotations:2.17.2' +} + +bootJar { + enabled = false +} + +test { + useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + + reports { + html.required = true + } +} \ No newline at end of file diff --git a/domain/src/main/java/com/example/app/booking/domain/Booking.java b/domain/src/main/java/com/example/app/booking/domain/Booking.java new file mode 100644 index 000000000..f254773f8 --- /dev/null +++ b/domain/src/main/java/com/example/app/booking/domain/Booking.java @@ -0,0 +1,17 @@ +package com.example.app.booking.domain; + +import lombok.Builder; + +import java.time.LocalDate; + +@Builder +public record Booking( + Long id, + Long userId, + Long movieId, + Long showtimeId, + Long theaterId, + Integer totalSeats, + LocalDate bookingDate +){ +} diff --git a/domain/src/main/java/com/example/app/booking/domain/Seat.java b/domain/src/main/java/com/example/app/booking/domain/Seat.java new file mode 100644 index 000000000..be2b745be --- /dev/null +++ b/domain/src/main/java/com/example/app/booking/domain/Seat.java @@ -0,0 +1,31 @@ +package com.example.app.booking.domain; + +import com.example.app.common.exception.APIException; +import com.example.app.movie.type.TheaterSeat; +import lombok.Builder; + +import java.time.LocalDate; +import java.util.List; + +import static com.example.app.booking.exception.BookingErrorMessage.SEAT_ALREADY_OCCUPIED; + +@Builder +public record Seat( + Long id, + Long bookingId, + Long movieId, + Long showtimeId, + Long theaterId, + LocalDate bookingDate, + TheaterSeat theaterSeat, + boolean reserved +) { + + public static void checkSeatsAvailable(List seats) { + for (Seat seat : seats) { + if (seat.reserved()) { + throw new APIException(SEAT_ALREADY_OCCUPIED); + } + } + } +} diff --git a/domain/src/main/java/com/example/app/booking/exception/BookingErrorMessage.java b/domain/src/main/java/com/example/app/booking/exception/BookingErrorMessage.java new file mode 100644 index 000000000..f5a301119 --- /dev/null +++ b/domain/src/main/java/com/example/app/booking/exception/BookingErrorMessage.java @@ -0,0 +1,17 @@ +package com.example.app.booking.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum BookingErrorMessage { + + SEAT_ROW_NOT_IN_SEQUENCE(HttpStatus.BAD_REQUEST, "열(row)이 다른 자리가 있어요"), + SEAT_ALREADY_OCCUPIED(HttpStatus.BAD_REQUEST, "이미 예약된 자리에요"), + OVER_MAX_LIMIT_SEATS(HttpStatus.BAD_REQUEST, "최대 5자리 예약이 가능해요"); + + private final HttpStatus httpStatus; + private final String message; +} diff --git a/domain/src/main/java/com/example/app/common/annotation/ClientIp.java b/domain/src/main/java/com/example/app/common/annotation/ClientIp.java new file mode 100644 index 000000000..61ae4d172 --- /dev/null +++ b/domain/src/main/java/com/example/app/common/annotation/ClientIp.java @@ -0,0 +1,11 @@ +package com.example.app.common.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface ClientIp { +} diff --git a/domain/src/main/java/com/example/app/common/annotation/ClientIpArgumentResolver.java b/domain/src/main/java/com/example/app/common/annotation/ClientIpArgumentResolver.java new file mode 100644 index 000000000..abd473845 --- /dev/null +++ b/domain/src/main/java/com/example/app/common/annotation/ClientIpArgumentResolver.java @@ -0,0 +1,45 @@ +package com.example.app.common.annotation; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.core.MethodParameter; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.Objects; +import java.util.stream.Stream; + +@Component +public class ClientIpArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter methodParameter) { + return methodParameter.hasParameterAnnotation(ClientIp.class); + } + + @Override + public String resolveArgument( + MethodParameter methodParameter, + ModelAndViewContainer modelAndViewContainer, + NativeWebRequest nativeWebRequest, + WebDataBinderFactory webDataBinderFactory) { + HttpServletRequest request = (HttpServletRequest) nativeWebRequest.getNativeRequest(); + + return getClientIp(request); + } + + private String getClientIp(HttpServletRequest request) { + return Stream.of( + request.getHeader("X-Forwarded-For"), + request.getHeader("Proxy-Client-IP"), + request.getHeader("WL-Proxy-Client-IP"), + request.getHeader("HTTP_CLIENT_IP"), + request.getHeader("HTTP_X_FORWARDED_FOR") + ) + .filter(Objects::nonNull) + .findFirst() + .orElse(request.getRemoteAddr()); + } +} diff --git a/domain/src/main/java/com/example/app/common/annotation/EnumValidator.java b/domain/src/main/java/com/example/app/common/annotation/EnumValidator.java new file mode 100644 index 000000000..0e8a69069 --- /dev/null +++ b/domain/src/main/java/com/example/app/common/annotation/EnumValidator.java @@ -0,0 +1,30 @@ +package com.example.app.common.annotation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class EnumValidator implements ConstraintValidator { + + Set values; + + @Override + public void initialize(ValidEnum constraintAnnotation) { + values = Stream.of(constraintAnnotation.enumClass().getEnumConstants()) + .map(Enum::name) + .collect(Collectors.toSet()); + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) { + return true; + } + + return values.contains(value); + } +} + diff --git a/domain/src/main/java/com/example/app/common/annotation/EnumsValidator.java b/domain/src/main/java/com/example/app/common/annotation/EnumsValidator.java new file mode 100644 index 000000000..2cd7a3ea4 --- /dev/null +++ b/domain/src/main/java/com/example/app/common/annotation/EnumsValidator.java @@ -0,0 +1,39 @@ +package com.example.app.common.annotation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +public class EnumsValidator implements ConstraintValidator> { + + Set enums; + + @Override + public void initialize(ValidEnums constraintAnnotation) { + enums = Stream.of(constraintAnnotation.enumClass().getEnumConstants()) + .map(Enum::name) + .collect(Collectors.toSet()); + } + + @Override + public boolean isValid(List values, ConstraintValidatorContext context) { + if (values == null || values.isEmpty()) { + return true; + } + + boolean isValid = true; + + for (String value : values) { + if (!enums.contains(value)) { + isValid = false; + break; + } + } + + return isValid; + } +} diff --git a/domain/src/main/java/com/example/app/common/annotation/ValidEnum.java b/domain/src/main/java/com/example/app/common/annotation/ValidEnum.java new file mode 100644 index 000000000..f9de8a11b --- /dev/null +++ b/domain/src/main/java/com/example/app/common/annotation/ValidEnum.java @@ -0,0 +1,19 @@ +package com.example.app.common.annotation; + +import jakarta.validation.Constraint; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Constraint(validatedBy = EnumValidator.class) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidEnum { + String message() default "올바른 코드값을 사용해주세요"; + Class[] groups() default {}; + Class[] payload() default {}; + Class> enumClass(); +} + diff --git a/domain/src/main/java/com/example/app/common/annotation/ValidEnums.java b/domain/src/main/java/com/example/app/common/annotation/ValidEnums.java new file mode 100644 index 000000000..f8734ad57 --- /dev/null +++ b/domain/src/main/java/com/example/app/common/annotation/ValidEnums.java @@ -0,0 +1,18 @@ +package com.example.app.common.annotation; + +import jakarta.validation.Constraint; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Constraint(validatedBy = EnumsValidator.class) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidEnums { + String message() default "올바른 코드값을 사용해주세요"; + Class[] groups() default {}; + Class[] payload() default {}; + Class> enumClass(); +} diff --git a/domain/src/main/java/com/example/app/common/dto/ErrorMessage.java b/domain/src/main/java/com/example/app/common/dto/ErrorMessage.java new file mode 100644 index 000000000..4e04f9473 --- /dev/null +++ b/domain/src/main/java/com/example/app/common/dto/ErrorMessage.java @@ -0,0 +1,4 @@ +package com.example.app.common.dto; + +public record ErrorMessage(String errorCode, String errorMessage) { +} diff --git a/domain/src/main/java/com/example/app/common/exception/APIException.java b/domain/src/main/java/com/example/app/common/exception/APIException.java new file mode 100644 index 000000000..7988fbd89 --- /dev/null +++ b/domain/src/main/java/com/example/app/common/exception/APIException.java @@ -0,0 +1,16 @@ +package com.example.app.common.exception; + +import com.example.app.booking.exception.BookingErrorMessage; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class APIException extends RuntimeException { + + private final HttpStatus httpStatus; + + public APIException(BookingErrorMessage errorMessage) { + super(errorMessage.getMessage()); + this.httpStatus = errorMessage.getHttpStatus(); + } +} diff --git a/domain/src/main/java/com/example/app/common/exception/ErrorAdviceController.java b/domain/src/main/java/com/example/app/common/exception/ErrorAdviceController.java new file mode 100644 index 000000000..db664dbbb --- /dev/null +++ b/domain/src/main/java/com/example/app/common/exception/ErrorAdviceController.java @@ -0,0 +1,44 @@ +package com.example.app.common.exception; + +import com.example.app.common.dto.ErrorMessage; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Objects; + +@Slf4j +@RestControllerAdvice +public class ErrorAdviceController { + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException ex) { + var message = Objects.requireNonNull(ex.getBindingResult().getFieldError()).getDefaultMessage(); + return new ResponseEntity<>(new ErrorMessage(HttpStatus.BAD_REQUEST.name(), message), HttpStatus.BAD_REQUEST); + } + + @ExceptionHandler(APIException.class) + public ResponseEntity handleAPIException(APIException ex) { + return new ResponseEntity<>(new ErrorMessage(ex.getHttpStatus().name(), ex.getMessage()), ex.getHttpStatus()); + } + + @ExceptionHandler(LockException.class) + public ResponseEntity handleLockException(LockException ex) { + return new ResponseEntity<>(new ErrorMessage(ex.getHttpStatus().name(), ex.getMessage()), ex.getHttpStatus()); + } + + @ExceptionHandler(RateLimitException.class) + public ResponseEntity handleRateLimitException(RateLimitException ex) { + return new ResponseEntity<>(new ErrorMessage(ex.getHttpStatus().name(), ex.getMessage()), ex.getHttpStatus()); + } + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity handleRuntimeException(RuntimeException ex) { + return new ResponseEntity<>( + new ErrorMessage(HttpStatus.INTERNAL_SERVER_ERROR.name(), ex.getMessage()), + HttpStatus.INTERNAL_SERVER_ERROR); + } +} diff --git a/domain/src/main/java/com/example/app/common/exception/LockException.java b/domain/src/main/java/com/example/app/common/exception/LockException.java new file mode 100644 index 000000000..f59135c10 --- /dev/null +++ b/domain/src/main/java/com/example/app/common/exception/LockException.java @@ -0,0 +1,15 @@ +package com.example.app.common.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class LockException extends RuntimeException { + + private final HttpStatus httpStatus; + + public LockException() { + super("리소스 충돌이 있어요"); + this.httpStatus = HttpStatus.CONFLICT; + } +} diff --git a/domain/src/main/java/com/example/app/common/exception/RateLimitException.java b/domain/src/main/java/com/example/app/common/exception/RateLimitException.java new file mode 100644 index 000000000..2c6c90320 --- /dev/null +++ b/domain/src/main/java/com/example/app/common/exception/RateLimitException.java @@ -0,0 +1,15 @@ +package com.example.app.common.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public class RateLimitException extends RuntimeException { + + private final HttpStatus httpStatus; + + public RateLimitException() { + super("요청이 너무 많아요"); + this.httpStatus = HttpStatus.TOO_MANY_REQUESTS; + } +} diff --git a/domain/src/main/java/com/example/app/config/WebMvcConfig.java b/domain/src/main/java/com/example/app/config/WebMvcConfig.java new file mode 100644 index 000000000..0ad8d8a56 --- /dev/null +++ b/domain/src/main/java/com/example/app/config/WebMvcConfig.java @@ -0,0 +1,20 @@ +package com.example.app.config; + +import com.example.app.common.annotation.ClientIpArgumentResolver; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class WebMvcConfig implements WebMvcConfigurer { + private final ClientIpArgumentResolver clientIpArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(clientIpArgumentResolver); + } +} diff --git a/domain/src/main/java/com/example/app/movie/domain/Movie.java b/domain/src/main/java/com/example/app/movie/domain/Movie.java new file mode 100644 index 000000000..c525ff9ee --- /dev/null +++ b/domain/src/main/java/com/example/app/movie/domain/Movie.java @@ -0,0 +1,26 @@ +package com.example.app.movie.domain; + +import com.example.app.movie.type.MovieGenre; +import com.example.app.movie.type.MovieRating; +import com.example.app.movie.type.MovieStatus; +import lombok.Builder; + +import java.time.LocalDate; +import java.util.List; + +@Builder +public record Movie( + Long id, + String title, + String description, + MovieStatus status, + MovieRating rating, + MovieGenre genre, + String thumbnail, + int runningTime, + LocalDate releaseDate, + List showtimes, + List theaters +){ + +} diff --git a/domain/src/main/java/com/example/app/movie/domain/Showtime.java b/domain/src/main/java/com/example/app/movie/domain/Showtime.java new file mode 100644 index 000000000..bca4bbb58 --- /dev/null +++ b/domain/src/main/java/com/example/app/movie/domain/Showtime.java @@ -0,0 +1,9 @@ +package com.example.app.movie.domain; + +import lombok.Builder; + +import java.time.LocalTime; + +@Builder +public record Showtime (LocalTime start, LocalTime end) { +} diff --git a/domain/src/main/java/com/example/app/movie/domain/Theater.java b/domain/src/main/java/com/example/app/movie/domain/Theater.java new file mode 100644 index 000000000..65086929b --- /dev/null +++ b/domain/src/main/java/com/example/app/movie/domain/Theater.java @@ -0,0 +1,7 @@ +package com.example.app.movie.domain; + +import lombok.Builder; + +@Builder +public record Theater(String name) { +} diff --git a/domain/src/main/java/com/example/app/movie/type/MovieGenre.java b/domain/src/main/java/com/example/app/movie/type/MovieGenre.java new file mode 100644 index 000000000..bc1d83d41 --- /dev/null +++ b/domain/src/main/java/com/example/app/movie/type/MovieGenre.java @@ -0,0 +1,17 @@ +package com.example.app.movie.type; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum MovieGenre { + ACTION("액션"), + COMEDY("코미디"), + FAMILY("가족"), + ROMANCE("로맨스"), + HORROR("호러"), + SF("SF"); + + private final String description; +} diff --git a/domain/src/main/java/com/example/app/movie/type/MovieRating.java b/domain/src/main/java/com/example/app/movie/type/MovieRating.java new file mode 100644 index 000000000..2261d2b97 --- /dev/null +++ b/domain/src/main/java/com/example/app/movie/type/MovieRating.java @@ -0,0 +1,16 @@ +package com.example.app.movie.type; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum MovieRating { + ALL_AGES("전체관람가"), + TWELVE_ABOVE("12세 이상 관람가"), + FIFTEEN_ABOVE("15세 이상 관람가"), + NO_MINORS("청소년 관람불가"), + RESTRICTED("제한상영가"); + + private final String description; +} diff --git a/domain/src/main/java/com/example/app/movie/type/MovieStatus.java b/domain/src/main/java/com/example/app/movie/type/MovieStatus.java new file mode 100644 index 000000000..97e8cad80 --- /dev/null +++ b/domain/src/main/java/com/example/app/movie/type/MovieStatus.java @@ -0,0 +1,14 @@ +package com.example.app.movie.type; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum MovieStatus { + SCHEDULED("상영예정"), + SHOWING("상영중"), + ENDED("상영종료"); + + private final String description; +} diff --git a/domain/src/main/java/com/example/app/movie/type/TheaterSeat.java b/domain/src/main/java/com/example/app/movie/type/TheaterSeat.java new file mode 100644 index 000000000..1b22d287a --- /dev/null +++ b/domain/src/main/java/com/example/app/movie/type/TheaterSeat.java @@ -0,0 +1,32 @@ +package com.example.app.movie.type; + +import com.example.app.common.exception.APIException; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Set; + +import static com.example.app.booking.exception.BookingErrorMessage.SEAT_ROW_NOT_IN_SEQUENCE; + +@Getter +@RequiredArgsConstructor +public enum TheaterSeat { + A1,A2,A3,A4,A5, + B1,B2,B3,B4,B5, + C1,C2,C3,C4,C5, + D1,D2,D3,D4,D5, + E1,E2,E3,E4,E5; + + public String getRow() { + return this.name().substring(0, 1); + } + + public static void checkSeatsInSequence(Set theaterSeats) { + String firstRow = theaterSeats.iterator().next().getRow(); + for (TheaterSeat theaterSeat : theaterSeats) { + if (!theaterSeat.getRow().equals(firstRow)) { + throw new APIException(SEAT_ROW_NOT_IN_SEQUENCE); + } + } + } +} diff --git a/domain/src/main/resources/application.yml b/domain/src/main/resources/application.yml new file mode 100644 index 000000000..e69de29bb diff --git a/domain/src/test/java/com/example/TestApplication.java b/domain/src/test/java/com/example/TestApplication.java new file mode 100644 index 000000000..e0ed55e30 --- /dev/null +++ b/domain/src/test/java/com/example/TestApplication.java @@ -0,0 +1,9 @@ +package com.example; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@SpringBootApplication +@EnableJpaAuditing +public class TestApplication { +} diff --git a/domain/src/test/java/com/example/app/booking/domain/SeatTest.java b/domain/src/test/java/com/example/app/booking/domain/SeatTest.java new file mode 100644 index 000000000..ddf89372f --- /dev/null +++ b/domain/src/test/java/com/example/app/booking/domain/SeatTest.java @@ -0,0 +1,30 @@ +package com.example.app.booking.domain; + +import com.example.app.common.exception.APIException; +import com.example.app.config.EmbeddedRedisConfig; +import com.example.app.movie.type.TheaterSeat; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.util.List; + +import static com.example.app.booking.exception.BookingErrorMessage.SEAT_ALREADY_OCCUPIED; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest(classes = EmbeddedRedisConfig.class) +@TestPropertySource(properties = "spring.config.location = classpath:application-test.yml") +public class SeatTest { + + @Test + public void 예약_가능_자리_여부_테스트() { + var hasReservedSeats = List.of( + Seat.builder().theaterSeat(TheaterSeat.A1).reserved(false).build(), + Seat.builder().theaterSeat(TheaterSeat.A2).reserved(true).build(), + Seat.builder().theaterSeat(TheaterSeat.A3).reserved(false).build()); + + var exception = assertThrows(APIException.class, () -> Seat.checkSeatsAvailable(hasReservedSeats)); + assertEquals(SEAT_ALREADY_OCCUPIED.getMessage(), exception.getMessage()); + } +} diff --git a/domain/src/test/java/com/example/app/config/EmbeddedRedisConfig.java b/domain/src/test/java/com/example/app/config/EmbeddedRedisConfig.java new file mode 100644 index 000000000..368d74cc5 --- /dev/null +++ b/domain/src/test/java/com/example/app/config/EmbeddedRedisConfig.java @@ -0,0 +1,29 @@ +package com.example.app.config; + +import jakarta.annotation.PreDestroy; +import jakarta.annotation.PostConstruct; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +import org.springframework.boot.test.context.TestConfiguration; +import redis.embedded.RedisServer; + +import java.io.IOException; + +@TestConfiguration +public class EmbeddedRedisConfig { + + private final RedisServer redisServer; + + public EmbeddedRedisConfig(RedisProperties redisProperties) throws IOException { + this.redisServer = new RedisServer(redisProperties.getPort()); + } + + @PostConstruct + public void postConstruct() throws IOException { + redisServer.start(); + } + + @PreDestroy + public void preDestroy() throws IOException { + redisServer.stop(); + } +} diff --git a/domain/src/test/java/com/example/app/movie/type/TheaterSeatTest.java b/domain/src/test/java/com/example/app/movie/type/TheaterSeatTest.java new file mode 100644 index 000000000..0e8190613 --- /dev/null +++ b/domain/src/test/java/com/example/app/movie/type/TheaterSeatTest.java @@ -0,0 +1,34 @@ +package com.example.app.movie.type; + +import com.example.app.common.exception.APIException; +import com.example.app.config.EmbeddedRedisConfig; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.util.Set; + +import static com.example.app.booking.exception.BookingErrorMessage.SEAT_ROW_NOT_IN_SEQUENCE; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest(classes = EmbeddedRedisConfig.class) +@TestPropertySource(properties = "spring.config.location = classpath:application-test.yml") +public class TheaterSeatTest { + + @Test + public void 열_조회_테스트() { + assertEquals("A", TheaterSeat.A1.getRow()); + assertEquals("B", TheaterSeat.B1.getRow()); + assertEquals("C", TheaterSeat.C1.getRow()); + assertEquals("D", TheaterSeat.D1.getRow()); + assertEquals("E", TheaterSeat.E1.getRow()); + } + + @Test + public void 연속된_열_체크_테스트() { + var discontinuousSeats = Set.of(TheaterSeat.B1, TheaterSeat.C1, TheaterSeat.D1); + var exception = assertThrows(APIException.class, () -> TheaterSeat.checkSeatsInSequence(discontinuousSeats)); + assertEquals(SEAT_ROW_NOT_IN_SEQUENCE.getMessage(), exception.getMessage()); + } +} diff --git a/domain/src/test/resources/application-test.yml b/domain/src/test/resources/application-test.yml new file mode 100644 index 000000000..77349edd9 --- /dev/null +++ b/domain/src/test/resources/application-test.yml @@ -0,0 +1,15 @@ +spring: + datasource : + url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driverClassName: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect + show-sql: true + data: + redis: + host: localhost + port: 6379 \ No newline at end of file diff --git a/etc/database/ddl.sql b/etc/database/ddl.sql new file mode 100644 index 000000000..eba291fd3 --- /dev/null +++ b/etc/database/ddl.sql @@ -0,0 +1,89 @@ +CREATE DATABASE `my` DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci; + +USE `my`; + +CREATE TABLE `tb_movie` ( + `movie_id` int unsigned NOT NULL AUTO_INCREMENT, + `title` varchar(255) NOT NULL, + `description` varchar(1000) DEFAULT NULL, + `status` varchar(20) NOT NULL COMMENT '영화 상태 (상영중, 상영종료, 상영예정)', + `rating` varchar(20) NOT NULL, + `genre` varchar(20) NOT NULL, + `thumbnail` varchar(255) DEFAULT NULL COMMENT '썸네일 url', + `running_time` smallint NOT NULL COMMENT '영화시간', + `release_date` date NOT NULL, + `updated_at` datetime NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`movie_id`) +) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE `tb_movie_showtime` ( + `showtime_id` int unsigned NOT NULL AUTO_INCREMENT, + `movie_id` int unsigned NOT NULL, + `start` time NOT NULL COMMENT '상영시작시간', + `end` time NOT NULL COMMENT '상영종료시간', + `updated_at` datetime NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`showtime_id`) +) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE `tb_movie_theater_rel` ( + `movie_theater_rel_id` int unsigned NOT NULL AUTO_INCREMENT, + `movie_id` int unsigned NOT NULL, + `theater_id` int unsigned NOT NULL, + `updated_at` datetime NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`movie_theater_rel_id`) +) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE `tb_theater` ( + `theater_id` int unsigned NOT NULL AUTO_INCREMENT, + `name` varchar(255) NOT NULL, + `updated_at` datetime NOT NULL, + `created_at` datetime NOT NULL, + PRIMARY KEY (`theater_id`) +) ENGINE=InnoDB AUTO_INCREMENT=10 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE `my`.`tb_booking` ( + `booking_id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `user_id` INT UNSIGNED NOT NULL, + `movie_id` INT UNSIGNED NOT NULL, + `showtime_id` INT UNSIGNED NOT NULL, + `booking_date` DATE NOT NULL, + `theater_id` INT UNSIGNED NOT NULL, + `total_seats` TINYINT(3) UNSIGNED NOT NULL, + `updated_at` DATETIME NOT NULL, + `created_at` DATETIME NOT NULL, +PRIMARY KEY (`booking_id`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + +CREATE TABLE `my`.`tb_seat` ( + `seat_id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `booking_id` INT UNSIGNED NULL, + `movie_id` INT UNSIGNED NOT NULL, + `theater_id` INT UNSIGNED NOT NULL, + `showtime_id` INT UNSIGNED NOT NULL, + `booking_date` DATE NOT NULL, + `theaterSeat` VARCHAR(3) NOT NULL, + `reserved` TINYINT(1) NOT NULL DEFAULT 1, + `version` INT UNSIGNED NOT NULL, + `updated_at` DATETIME NOT NULL, + `created_at` DATETIME NOT NULL, +PRIMARY KEY (`seat_id`) +) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci; + + +ALTER TABLE `my`.`tb_movie` + ADD INDEX `idx_genre_title_release_date` (`genre` ASC, `title` ASC, `release_date` DESC) VISIBLE, +ADD INDEX `idx_genre` (`genre` ASC) VISIBLE, +ADD INDEX `idx_title` (`title` ASC) VISIBLE; +; + + +ALTER TABLE `my`.`tb_movie_showtime` + ADD INDEX `idx_movie_id` (`movie_id` ASC) VISIBLE; +; + +ALTER TABLE `my`.`tb_movie_theater_rel` + ADD INDEX `idx_movie_id` (`movie_id` ASC) VISIBLE; +; \ No newline at end of file diff --git a/etc/database/dml.sql b/etc/database/dml.sql new file mode 100644 index 000000000..27684e027 --- /dev/null +++ b/etc/database/dml.sql @@ -0,0 +1,68 @@ +USE `my`; + +INSERT INTO `tb_movie` (`movie_id`,`title`,`description`,`status`,`rating`,`genre`,`thumbnail`,`running_time`,`release_date`,`updated_at`,`created_at`) VALUES (1,'나 홀로 집에','크리스마스 휴가 당일, 늦잠을 자 정신없이 공항으로 출발한 맥콜리스터 가족은 전날 부린 말썽에 대한 벌로 다락방에 들어가 있던 8살 케빈을 깜박 잊고 프랑스로 떠나버린다. 매일 형제들에게 치이며 가족이 전부 없어졌으면 좋겠다고 생각한 케빈은 갑자기 찾아온 자유를 만끽한다.','SHOWING','ALL_AGES','COMEDY','https://m.media-amazon.com/images/M/MV5BNzNmNmQ2ZDEtMTc1MS00NjNiLThlMGUtZmQxNTg1Nzg5NWMzXkEyXkFqcGc@._V1_QL75_UX190_CR0,1,190,281_.jpg',103,'1991-07-06','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie` (`movie_id`,`title`,`description`,`status`,`rating`,`genre`,`thumbnail`,`running_time`,`release_date`,`updated_at`,`created_at`) VALUES (2,'탑건','최고의 파일럿들만이 갈 수 있는 캘리포니아의 한 비행 조종 학교 탑건에서의 사나이들의 우정과 사랑의 모험이 시작된다. 자신을 좇는 과거의 기억과 경쟁자, 그리고 사랑 사이에서 고군분투하는 그의 여정이 펼쳐진다.','SHOWING','FIFTEEN_ABOVE','ACTION','https://m.media-amazon.com/images/M/MV5BZmVjNzQ3MjYtYTZiNC00Y2YzLWExZTEtMTM2ZDllNDI0MzgyXkEyXkFqcGc@._V1_QL75_UX190_CR0,6,190,281_.jpg',109,'1986-05-12','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie` (`movie_id`,`title`,`description`,`status`,`rating`,`genre`,`thumbnail`,`running_time`,`release_date`,`updated_at`,`created_at`) VALUES (3,'탑건:메버릭','해군 최고의 비행사 피트 미첼은 비행 훈련소에서 갓 졸업을 한 신입 비행사들 팀의 훈련을 맡게 된다. 자신을 좇는 과거의 기억과 위험천만한 임무 속에서 고군분투하는 그의 비상이 펼쳐진다.','SHOWING','TWELVE_ABOVE','ACTION','https://m.media-amazon.com/images/M/MV5BMDBkZDNjMWEtOTdmMi00NmExLTg5MmMtNTFlYTJlNWY5YTdmXkEyXkFqcGc@._V1_QL75_UX190_CR0,0,190,281_.jpg',130,'2022-05-18','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie` (`movie_id`,`title`,`description`,`status`,`rating`,`genre`,`thumbnail`,`running_time`,`release_date`,`updated_at`,`created_at`) VALUES (4,'하얼빈','1908년 함경북도 신아산에서 안중근이 이끄는 독립군들은 일본군과의 전투에서 큰 승리를 거둔다. 대한의군 참모중장 안중근은 만국공법에 따라 전쟁포로인 일본인들을 풀어주게 되고, 이 사건으로 인해 독립군 사이에서는 안중근에 대한 의심과 함께 균열이 일기 시작한다.','SCHEDULED','FIFTEEN_ABOVE','ACTION','https://m.media-amazon.com/images/M/MV5BNmY4YzM5NzUtMTg4Yy00Yzc3LThlZDktODk4YjljZmNlODA0XkEyXkFqcGc@._V1_QL75_UY281_CR4,0,190,281_.jpg',113,'2024-12-24','2025-01-09 00:00:00','2025-01-09 00:00:00'); + +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,`start`,`end`,`updated_at`,`created_at`) VALUES (1,1,'08:00:00','09:45:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,`start`,`end`,`updated_at`,`created_at`) VALUES (2,1,'10:00:00','11:45:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,`start`,`end`,`updated_at`,`created_at`) VALUES (3,1,'13:00:00','14:45:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,`start`,`end`,`updated_at`,`created_at`) VALUES (4,1,'15:30:00','17:15:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,`start`,`end`,`updated_at`,`created_at`) VALUES (5,2,'10:30:00','12:15:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,`start`,`end`,`updated_at`,`created_at`) VALUES (6,2,'14:30:00','16:15:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,`start`,`end`,`updated_at`,`created_at`) VALUES (7,3,'11:30:00','14:15:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,`start`,`end`,`updated_at`,`created_at`) VALUES (8,3,'15:40:00','17:25:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,`start`,`end`,`updated_at`,`created_at`) VALUES (9,3,'18:50:00','20:45:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,`start`,`end`,`updated_at`,`created_at`) VALUES (10,3,'07:30:00','09:50:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,`start`,`end`,`updated_at`,`created_at`) VALUES (11,4,'11:10:00','13:05:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); + +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (1,'강남점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (2,'강북점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (3,'봉천점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (4,'안양점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (5,'평촌점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (6,'인덕원점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (7,'사당점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (8,'삼성점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (9,'신림점','2025-01-09 00:00:00','2025-01-09 00:00:00'); + +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (1,1,1,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (2,2,3,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (3,3,4,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (4,1,4,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (5,1,5,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (6,2,5,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (7,2,1,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (8,3,2,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (9,4,8,'2025-01-09 00:00:00','2025-01-09 00:00:00'); + +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'A1', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'A2', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'A3', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'A4', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'A5', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); + +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'B1', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'B2', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'B3', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'B4', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'B5', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); + +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'C1', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'C2', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'C3', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'C4', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'C5', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); + +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'D1', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'D2', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'D3', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'D4', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'D5', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); + +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'E1', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'E2', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'E3', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'E4', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); +INSERT INTO `my`.`tb_seat` (`movie_id`, `theater_id`, `showtime_id`, `booking_date`, `seat`, `reserved`, `version`, `updated_at`, `created_at`) VALUES ('2', '1', '5', '2025-03-01', 'E5', '0', '0', '2025-01-25 00:00:00', '2025-01-25 00:00:00'); diff --git a/etc/http/booking.http b/etc/http/booking.http new file mode 100644 index 000000000..650e211aa --- /dev/null +++ b/etc/http/booking.http @@ -0,0 +1,23 @@ +### +POST http://localhost:8090/api/v1/booking +Content-Type: application/json + +{ "userId": 1, + "movieId": 2, + "showtimeId": 5, + "theaterId": 1, + "bookingDate": "2025-03-01", + "seats": ["A1", "A2", "A3"] +} + +### +POST http://localhost:8090/api/v1/booking +Content-Type: application/json + +{ "userId": 1, + "movieId": 2, + "showtimeId": 5, + "theaterId": 1, + "bookingDate": "2025-03-01", + "seats": ["B1", "B2"] +} \ No newline at end of file diff --git a/etc/http/movies.http b/etc/http/movies.http new file mode 100644 index 000000000..2ffe6b697 --- /dev/null +++ b/etc/http/movies.http @@ -0,0 +1,11 @@ +### +GET http://localhost:8080/api/v1/movies + +### +GET http://localhost:8080/api/v1/movies?genre=COMEDY&title=%EC%8B%A0 + +### +GET http://localhost:8080/api/v1/movies?genre=SF&title=17 + +### +GET http://localhost:8080/api/v1/movies?genre=ACTION \ No newline at end of file diff --git a/etc/k6/cache-test.js b/etc/k6/cache-test.js new file mode 100644 index 000000000..410a7c0ce --- /dev/null +++ b/etc/k6/cache-test.js @@ -0,0 +1,54 @@ +import http from 'k6/http'; +import { sleep } from 'k6'; + +// 공통 설정 +const BASE_URL = 'http://localhost:8080'; + +// 타이틀과 장르의 예제 배열 +const titles = Array.from({length: 20}, (_, i) => `${i + 1}`); +const genres = [null, 'ACTION', 'COMEDY', 'SF']; + +// 랜덤한 타이틀 가져오기 +function getRandomElement(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} + +// 부하 테스트 옵션 +export const options = { + stages: [ + { duration: '2m', target: 100 }, // 초기 부하, 2m 동안 VU를 0에서 100으로 점진적으로 증가시킵니다. + { duration: '5m', target: 1000 }, // 최대 부하로 증가, 5m 동안 VU를 100에서 1000으로 점진적으로 증가시킵니다. + { duration: '2m', target: 0 } // 종료, 2m 동안 VU를 1000에서 0으로 점진적으로 감소시킵니다. + ], + thresholds: { + // 사용자 경험을 위한 주요 성능 지표 + http_req_duration: ['p(95)<200'], // 95%의 요청이 200ms 이하 + // 서비스 안정성 보장을 위한 조건 + http_req_failed: ['rate<0.01'], // 실패율 1% 미만 + } +}; + +// 요청 생성 함수 +function makeRequest() { + const randomTitle = getRandomElement(titles); + const randomGenre = getRandomElement(genres); + + // 쿼리 문자열 수동 생성 + const queryParams = []; + if (randomTitle) { + queryParams.push(`title=${encodeURIComponent(randomTitle)}`); + } + if (randomGenre) { + queryParams.push(`genre=${encodeURIComponent(randomGenre)}`); + } + + const queryString = queryParams.length > 0 ? `?${queryParams.join('&')}` : ''; + + // 요청 보내기 + http.get(`${BASE_URL}/api/v1/movies${queryString}`); + sleep(0.5); +} + +export default function() { + makeRequest(); +} \ No newline at end of file diff --git a/etc/k6/lock-test.js b/etc/k6/lock-test.js new file mode 100644 index 000000000..0d2167874 --- /dev/null +++ b/etc/k6/lock-test.js @@ -0,0 +1,47 @@ +import http from 'k6/http'; +import { sleep } from 'k6'; + +// 공통 설정 +const BASE_URL = 'http://localhost:8090'; + +const userIds = Array.from({ length: 10 }, (_, i) => `${i}`); +const movieIds = Array.from({ length: 20 }, (_, i) => `${i}`); +const seats = ['A1','A2','A3','A4','A5','B1','B2','B3','B4','B5','C1','C2','C3','C4','C5','D1','D2','D3','D4','D5','E1','E2','E3','E4','E5']; + +function getRandomElement(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} + +// 테스트 옵션 +export const options = { + duration: '1m', + vus: 10, + thresholds: { + // 사용자 경험을 위한 주요 성능 지표 + http_req_duration: ['p(95)<200'], // 95%의 요청이 200ms 이하 + } +} + +// 요청 생성 +function makeRequest() { + const params = { headers: { 'Content-Type': 'application/json' } }; + const userId = Number(getRandomElement(userIds)); + const movieId = Number(getRandomElement(movieIds)); + const seat1 = getRandomElement(seats); + + const payload = JSON.stringify({ + userId, + movieId, + showtimeId: 5, + theaterId: 1, + bookingDate: '2025-03-01', + seats: [seat1] + }); + + http.post(`${BASE_URL}/api/v1/booking`, payload, params); + sleep(0.5); +} + +export default function() { + makeRequest(); +} \ No newline at end of file diff --git a/etc/readme/arc.png b/etc/readme/arc.png new file mode 100644 index 000000000..73b47fba8 Binary files /dev/null and b/etc/readme/arc.png differ diff --git a/etc/readme/arc2.png b/etc/readme/arc2.png new file mode 100644 index 000000000..e9560cdd4 Binary files /dev/null and b/etc/readme/arc2.png differ diff --git a/etc/readme/arc3.png b/etc/readme/arc3.png new file mode 100644 index 000000000..25d39b498 Binary files /dev/null and b/etc/readme/arc3.png differ diff --git a/etc/readme/erd.png b/etc/readme/erd.png new file mode 100644 index 000000000..25fe91af8 Binary files /dev/null and b/etc/readme/erd.png differ diff --git a/etc/readme/erd2.png b/etc/readme/erd2.png new file mode 100644 index 000000000..ec621ae07 Binary files /dev/null and b/etc/readme/erd2.png differ diff --git a/etc/readme/j_a.png b/etc/readme/j_a.png new file mode 100644 index 000000000..add65f215 Binary files /dev/null and b/etc/readme/j_a.png differ diff --git a/etc/readme/j_b.png b/etc/readme/j_b.png new file mode 100644 index 000000000..4588c174b Binary files /dev/null and b/etc/readme/j_b.png differ diff --git a/etc/readme/j_d.png b/etc/readme/j_d.png new file mode 100644 index 000000000..5cb82a9cd Binary files /dev/null and b/etc/readme/j_d.png differ diff --git a/etc/readme/j_i.png b/etc/readme/j_i.png new file mode 100644 index 000000000..69937f624 Binary files /dev/null and b/etc/readme/j_i.png differ diff --git a/etc/readme/j_m.png b/etc/readme/j_m.png new file mode 100644 index 000000000..c1d0773db Binary files /dev/null and b/etc/readme/j_m.png differ diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..d64cd4917 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..1af9e0930 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 000000000..1aa94a426 --- /dev/null +++ b/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000..93e3f59f1 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/infrastructure/.gitignore b/infrastructure/.gitignore new file mode 100644 index 000000000..c31ae9fd0 --- /dev/null +++ b/infrastructure/.gitignore @@ -0,0 +1,36 @@ +../.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ \ No newline at end of file diff --git a/infrastructure/build.gradle b/infrastructure/build.gradle new file mode 100644 index 000000000..c39a073e6 --- /dev/null +++ b/infrastructure/build.gradle @@ -0,0 +1,45 @@ +dependencies { + implementation project(':domain') + implementation project(':application') + + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta' + annotationProcessor 'jakarta.annotation:jakarta.annotation-api' + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + + implementation 'org.mapstruct:mapstruct:1.5.5.Final' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final' + + runtimeOnly 'com.mysql:mysql-connector-j' +} + +def querydslDir = "$buildDir/generated/qclass" + +sourceSets { + main.java.srcDirs += [querydslDir] +} + +tasks.withType(JavaCompile) { + options.generatedSourceOutputDirectory = file(querydslDir) +} + +clean { + delete file(querydslDir) +} + +bootJar { + enabled = false +} + +test { + useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + + reports { + html.required = true + } +} \ No newline at end of file diff --git a/infrastructure/src/main/java/com/example/app/booking/out/persistence/adapter/BookingPersistenceAdapter.java b/infrastructure/src/main/java/com/example/app/booking/out/persistence/adapter/BookingPersistenceAdapter.java new file mode 100644 index 000000000..2d0eb5504 --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/booking/out/persistence/adapter/BookingPersistenceAdapter.java @@ -0,0 +1,54 @@ +package com.example.app.booking.out.persistence.adapter; + +import com.example.app.booking.domain.Booking; +import com.example.app.booking.dto.CreateBookingCommand; +import com.example.app.booking.dto.SearchBookingCommand; +import com.example.app.booking.out.persistence.mapper.BookingMapper; +import com.example.app.booking.out.persistence.repository.BookingRepository; +import com.example.app.booking.port.CreateBookingPort; +import com.example.app.booking.port.LoadBookingPort; +import com.querydsl.core.types.ExpressionUtils; +import com.querydsl.core.types.Predicate; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static com.example.app.booking.out.persistence.entity.QBookingEntity.bookingEntity; +import static java.util.Objects.nonNull; + +@Repository +@RequiredArgsConstructor +public class BookingPersistenceAdapter implements CreateBookingPort, LoadBookingPort { + + private final BookingRepository bookingRepository; + private final BookingMapper bookingMapper; + + @Override + public List loadAllBookings(SearchBookingCommand searchBookingCommand) { + return bookingRepository.findAllBy(toPredicate(searchBookingCommand)) + .stream() + .map(bookingMapper::bookingEntityToBooking) + .toList(); + } + + private Predicate toPredicate(SearchBookingCommand searchBookingCommand) { + return ExpressionUtils.allOf( + nonNull(searchBookingCommand.userId()) ? + bookingEntity.userId.eq(searchBookingCommand.userId()) : null, + nonNull(searchBookingCommand.movieId()) ? + bookingEntity.movieId.eq(searchBookingCommand.movieId()) : null, + nonNull(searchBookingCommand.showtimeId()) ? + bookingEntity.showtimeId.eq(searchBookingCommand.showtimeId()) : null, + nonNull(searchBookingCommand.theaterId()) ? + bookingEntity.theaterId.eq(searchBookingCommand.theaterId()) : null, + nonNull(searchBookingCommand.bookingDate()) ? + bookingEntity.bookingDate.eq(searchBookingCommand.bookingDate()) : null); + } + + @Override + public Booking saveBooking(CreateBookingCommand createBookingCommand) { + var entity = bookingRepository.save(bookingMapper.bookingToBookingEntity(createBookingCommand.toBooking())); + return bookingMapper.bookingEntityToBooking(entity); + } +} diff --git a/infrastructure/src/main/java/com/example/app/booking/out/persistence/adapter/SeatPersistenceAdapter.java b/infrastructure/src/main/java/com/example/app/booking/out/persistence/adapter/SeatPersistenceAdapter.java new file mode 100644 index 000000000..49d1f835f --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/booking/out/persistence/adapter/SeatPersistenceAdapter.java @@ -0,0 +1,66 @@ +package com.example.app.booking.out.persistence.adapter; + +import com.example.app.booking.domain.Seat; +import com.example.app.booking.dto.SearchSeatCommand; +import com.example.app.booking.out.persistence.entity.SeatEntity; +import com.example.app.booking.out.persistence.mapper.SeatMapper; +import com.example.app.booking.out.persistence.repository.SeatRepository; +import com.example.app.booking.port.LoadSeatPort; +import com.example.app.booking.port.UpdateSeatPort; +import com.querydsl.core.types.ExpressionUtils; +import com.querydsl.core.types.Predicate; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static com.example.app.booking.out.persistence.entity.QSeatEntity.seatEntity; +import static java.util.Objects.nonNull; + +@Repository +@RequiredArgsConstructor +public class SeatPersistenceAdapter implements LoadSeatPort, UpdateSeatPort { + + private final SeatRepository seatRepository; + private final SeatMapper seatMapper; + + @Override + public List updateAllSeats(List seatIds, Long bookingId) { + var seatEntities = seatRepository.findAllById(seatIds); + + for (SeatEntity seat : seatEntities) { + seat.occupySeat(bookingId); + } + + return seatRepository.saveAll(seatEntities) + .stream() + .map(seatMapper::seatEnityToSeat) + .toList(); + } + + @Override + public List loadAllSeatsByBookingIds(List bookingIds) { + return seatRepository.findAllByBookingIdIn(bookingIds) + .stream() + .map(seatMapper::seatEnityToSeat) + .toList(); + } + + @Override + public List loadAllSeats(SearchSeatCommand searchSeatCommand) { + return seatRepository.findAllBy(toPredicate(searchSeatCommand)) + .stream() + .map(seatMapper::seatEnityToSeat) + .toList(); + } + + private Predicate toPredicate(SearchSeatCommand searchSeatCommand) { + return ExpressionUtils.allOf( + nonNull(searchSeatCommand.movieId()) ? seatEntity.movieId.eq(searchSeatCommand.movieId()) : null, + nonNull(searchSeatCommand.showtimeId()) ? seatEntity.showtimeId.eq(searchSeatCommand.showtimeId()) : null, + nonNull(searchSeatCommand.theaterId()) ? seatEntity.theaterId.eq(searchSeatCommand.theaterId()) : null, + nonNull(searchSeatCommand.bookingDate()) ? seatEntity.bookingDate.eq(searchSeatCommand.bookingDate()) : null, + searchSeatCommand.seats().isEmpty() ? null : seatEntity.seat.in(searchSeatCommand.seats()) + ); + } +} diff --git a/infrastructure/src/main/java/com/example/app/booking/out/persistence/entity/BookingEntity.java b/infrastructure/src/main/java/com/example/app/booking/out/persistence/entity/BookingEntity.java new file mode 100644 index 000000000..353de106c --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/booking/out/persistence/entity/BookingEntity.java @@ -0,0 +1,42 @@ +package com.example.app.booking.out.persistence.entity; + +import com.example.app.common.BaseEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Table(name="tb_booking") +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class BookingEntity extends BaseEntity { + + @Id + @Column(name = "booking_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "movie_id") + private Long movieId; + + @Column(name = "showtime_id") + private Long showtimeId; + + @Column(name = "booking_date") + private LocalDate bookingDate; + + @Column(name = "theater_id") + private Long theaterId; + + @Column(name = "total_seats") + private Integer totalSeats; +} diff --git a/infrastructure/src/main/java/com/example/app/booking/out/persistence/entity/SeatEntity.java b/infrastructure/src/main/java/com/example/app/booking/out/persistence/entity/SeatEntity.java new file mode 100644 index 000000000..29e75026e --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/booking/out/persistence/entity/SeatEntity.java @@ -0,0 +1,53 @@ +package com.example.app.booking.out.persistence.entity; + +import com.example.app.movie.type.TheaterSeat; +import com.example.app.common.BaseEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +@Entity +@Table(name="tb_seat") +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class SeatEntity extends BaseEntity { + + @Id + @Column(name = "seat_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "booking_id") + private Long bookingId; + + @Column(name = "movie_id") + private Long movieId; + + @Column(name = "showtime_id") + private Long showtimeId; + + @Column(name = "theater_id") + private Long theaterId; + + @Column(name = "booking_date") + private LocalDate bookingDate; + + @Enumerated(EnumType.STRING) + private TheaterSeat seat; + + private boolean reserved; + + @Version + private Long version; + + public void occupySeat(Long bookingId) { + this.reserved = true; + this.bookingId = bookingId; + } +} diff --git a/infrastructure/src/main/java/com/example/app/booking/out/persistence/mapper/BookingMapper.java b/infrastructure/src/main/java/com/example/app/booking/out/persistence/mapper/BookingMapper.java new file mode 100644 index 000000000..6d8447832 --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/booking/out/persistence/mapper/BookingMapper.java @@ -0,0 +1,16 @@ +package com.example.app.booking.out.persistence.mapper; + +import com.example.app.booking.domain.Booking; +import com.example.app.booking.out.persistence.entity.BookingEntity; +import org.mapstruct.Mapper; +import org.mapstruct.factory.Mappers; + +@Mapper(componentModel = "spring") +public interface BookingMapper { + + BookingMapper INSTANCE = Mappers.getMapper(BookingMapper.class); + + Booking bookingEntityToBooking(BookingEntity bookingEntity); + + BookingEntity bookingToBookingEntity(Booking booking); +} diff --git a/infrastructure/src/main/java/com/example/app/booking/out/persistence/mapper/SeatMapper.java b/infrastructure/src/main/java/com/example/app/booking/out/persistence/mapper/SeatMapper.java new file mode 100644 index 000000000..3aea20ef7 --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/booking/out/persistence/mapper/SeatMapper.java @@ -0,0 +1,22 @@ +package com.example.app.booking.out.persistence.mapper; + +import com.example.app.booking.domain.Seat; +import com.example.app.booking.out.persistence.entity.SeatEntity; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public abstract class SeatMapper { + + public Seat seatEnityToSeat(SeatEntity seatEntity) { + return Seat.builder() + .id(seatEntity.getId()) + .bookingId(seatEntity.getBookingId()) + .movieId(seatEntity.getMovieId()) + .showtimeId(seatEntity.getShowtimeId()) + .theaterId(seatEntity.getTheaterId()) + .bookingDate(seatEntity.getBookingDate()) + .reserved(seatEntity.isReserved()) + .theaterSeat(seatEntity.getSeat()) + .build(); + } +} diff --git a/infrastructure/src/main/java/com/example/app/booking/out/persistence/repository/BookingRepository.java b/infrastructure/src/main/java/com/example/app/booking/out/persistence/repository/BookingRepository.java new file mode 100644 index 000000000..5ddf605f1 --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/booking/out/persistence/repository/BookingRepository.java @@ -0,0 +1,7 @@ +package com.example.app.booking.out.persistence.repository; + +import com.example.app.booking.out.persistence.entity.BookingEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BookingRepository extends JpaRepository, BookingRepositoryCustom { +} diff --git a/infrastructure/src/main/java/com/example/app/booking/out/persistence/repository/BookingRepositoryCustom.java b/infrastructure/src/main/java/com/example/app/booking/out/persistence/repository/BookingRepositoryCustom.java new file mode 100644 index 000000000..24b4da7e1 --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/booking/out/persistence/repository/BookingRepositoryCustom.java @@ -0,0 +1,11 @@ +package com.example.app.booking.out.persistence.repository; + +import com.example.app.booking.out.persistence.entity.BookingEntity; +import com.querydsl.core.types.Predicate; + +import java.util.List; + +public interface BookingRepositoryCustom { + + List findAllBy(Predicate predicate); +} diff --git a/infrastructure/src/main/java/com/example/app/booking/out/persistence/repository/BookingRepositoryCustomImpl.java b/infrastructure/src/main/java/com/example/app/booking/out/persistence/repository/BookingRepositoryCustomImpl.java new file mode 100644 index 000000000..a3737c8b6 --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/booking/out/persistence/repository/BookingRepositoryCustomImpl.java @@ -0,0 +1,25 @@ +package com.example.app.booking.out.persistence.repository; + +import static com.example.app.booking.out.persistence.entity.QBookingEntity.bookingEntity; + +import com.example.app.booking.out.persistence.entity.BookingEntity; +import com.querydsl.core.types.Predicate; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +@RequiredArgsConstructor +public class BookingRepositoryCustomImpl implements BookingRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List findAllBy(Predicate predicate) { + return queryFactory + .select(bookingEntity) + .from(bookingEntity) + .where(predicate) + .fetch(); + } +} diff --git a/infrastructure/src/main/java/com/example/app/booking/out/persistence/repository/SeatRepository.java b/infrastructure/src/main/java/com/example/app/booking/out/persistence/repository/SeatRepository.java new file mode 100644 index 000000000..3f3e59b86 --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/booking/out/persistence/repository/SeatRepository.java @@ -0,0 +1,11 @@ +package com.example.app.booking.out.persistence.repository; + +import com.example.app.booking.out.persistence.entity.SeatEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface SeatRepository extends JpaRepository, SeatRepositoryCustom { + + List findAllByBookingIdIn(List bookingIds); +} diff --git a/infrastructure/src/main/java/com/example/app/booking/out/persistence/repository/SeatRepositoryCustom.java b/infrastructure/src/main/java/com/example/app/booking/out/persistence/repository/SeatRepositoryCustom.java new file mode 100644 index 000000000..ae33d6128 --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/booking/out/persistence/repository/SeatRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.example.app.booking.out.persistence.repository; + +import com.example.app.booking.out.persistence.entity.SeatEntity; +import com.querydsl.core.types.Predicate; + +import java.util.List; + +public interface SeatRepositoryCustom { + List findAllBy(Predicate predicate); +} diff --git a/infrastructure/src/main/java/com/example/app/booking/out/persistence/repository/SeatRepositoryCustomImpl.java b/infrastructure/src/main/java/com/example/app/booking/out/persistence/repository/SeatRepositoryCustomImpl.java new file mode 100644 index 000000000..5a41a4551 --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/booking/out/persistence/repository/SeatRepositoryCustomImpl.java @@ -0,0 +1,27 @@ +package com.example.app.booking.out.persistence.repository; + +import com.example.app.booking.out.persistence.entity.SeatEntity; +import com.querydsl.core.types.Predicate; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.LockModeType; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +import static com.example.app.booking.out.persistence.entity.QSeatEntity.seatEntity; + +@RequiredArgsConstructor +public class SeatRepositoryCustomImpl implements SeatRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List findAllBy(Predicate predicate) { + return queryFactory + .select(seatEntity) + .from(seatEntity) + .where(predicate) + .setLockMode(LockModeType.OPTIMISTIC) + .fetch(); + } +} diff --git a/infrastructure/src/main/java/com/example/app/common/BaseEntity.java b/infrastructure/src/main/java/com/example/app/common/BaseEntity.java new file mode 100644 index 000000000..6dd2708ca --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/common/BaseEntity.java @@ -0,0 +1,25 @@ +package com.example.app.common; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + @LastModifiedDate + @Column(nullable = false) + private LocalDateTime updatedAt; + + @CreatedDate + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; +} diff --git a/infrastructure/src/main/java/com/example/app/config/CacheConfig.java b/infrastructure/src/main/java/com/example/app/config/CacheConfig.java new file mode 100644 index 000000000..6a9c608e7 --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/config/CacheConfig.java @@ -0,0 +1,55 @@ +package com.example.app.config; + +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.redisson.spring.cache.RedissonSpringCacheManager; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.HashMap; +import java.util.Map; + +@EnableCaching +@Configuration +public class CacheConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer().setAddress("redis://" + host + ":" + port); + return Redisson.create(config); + } + + @Bean + public CacheManager cacheManager(RedissonClient redissonClient) { + Map config = new HashMap<>(); + + config.put("movies", new org.redisson.spring.cache.CacheConfig()); + config.get("movies").setTTL(5 * 60 * 1000); + + return new RedissonSpringCacheManager(redissonClient, config); + } + +// @Bean +// public Caffeine caffeineConfig() { +// return Caffeine.newBuilder().expireAfterWrite(5, TimeUnit.MINUTES); +// } +// +// @Bean +// public CacheManager cacheManager(Caffeine caffeine) { +// CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager(); +// caffeineCacheManager.setCaffeine(caffeine); +// +// return caffeineCacheManager; +// } +} diff --git a/infrastructure/src/main/java/com/example/app/config/QuerydslConfig.java b/infrastructure/src/main/java/com/example/app/config/QuerydslConfig.java new file mode 100644 index 000000000..59a831294 --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/config/QuerydslConfig.java @@ -0,0 +1,19 @@ +package com.example.app.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory queryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/infrastructure/src/main/java/com/example/app/config/RedisConfig.java b/infrastructure/src/main/java/com/example/app/config/RedisConfig.java new file mode 100644 index 000000000..b948adb7c --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/config/RedisConfig.java @@ -0,0 +1,20 @@ +package com.example.app.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + return template; + } +} diff --git a/infrastructure/src/main/java/com/example/app/movie/out/persistence/adapter/MoviePersistenceAdapter.java b/infrastructure/src/main/java/com/example/app/movie/out/persistence/adapter/MoviePersistenceAdapter.java new file mode 100644 index 000000000..28205b872 --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/movie/out/persistence/adapter/MoviePersistenceAdapter.java @@ -0,0 +1,41 @@ +package com.example.app.movie.out.persistence.adapter; + +import com.example.app.movie.domain.Movie; +import com.example.app.movie.dto.SearchMovieCommand; +import com.example.app.movie.out.persistence.mapper.MovieMapper; +import com.example.app.movie.out.persistence.repository.MovieRepository; +import com.example.app.movie.port.LoadMoviePort; +import com.querydsl.core.types.ExpressionUtils; +import com.querydsl.core.types.Predicate; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static com.example.app.movie.out.persistence.entity.QMovieEntity.movieEntity; +import static java.util.Objects.nonNull; + +@Repository +@RequiredArgsConstructor +public class MoviePersistenceAdapter implements LoadMoviePort { + + private final MovieRepository movieRepository; + private final MovieMapper movieMapper; + + @Override + public List loadAllMovies(SearchMovieCommand searchMovieCommand) { + return movieRepository.findAllBy(toPredicate(searchMovieCommand)) + .stream() + .map(movieMapper::movieEntityToMovie) + .toList(); + } + + private Predicate toPredicate(SearchMovieCommand searchMovieCommand) { + return ExpressionUtils.allOf( + nonNull(searchMovieCommand.title()) ? + movieEntity.title.containsIgnoreCase(searchMovieCommand.title()) : null, + nonNull(searchMovieCommand.genre()) ? + movieEntity.genre.eq(searchMovieCommand.genre()) : null + ); + } +} diff --git a/infrastructure/src/main/java/com/example/app/movie/out/persistence/entity/MovieEntity.java b/infrastructure/src/main/java/com/example/app/movie/out/persistence/entity/MovieEntity.java new file mode 100644 index 000000000..de1e31cce --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/movie/out/persistence/entity/MovieEntity.java @@ -0,0 +1,54 @@ +package com.example.app.movie.out.persistence.entity; + +import com.example.app.common.BaseEntity; +import com.example.app.movie.type.MovieGenre; +import com.example.app.movie.type.MovieRating; +import com.example.app.movie.type.MovieStatus; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.HashSet; +import java.util.Set; + +@Entity +@Table(name="tb_movie") +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class MovieEntity extends BaseEntity { + + @Id + @Column(name = "movie_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String title; + + private String description; + + @Enumerated(EnumType.STRING) + private MovieStatus status; + + @Enumerated(EnumType.STRING) + private MovieRating rating; + + @Enumerated(EnumType.STRING) + private MovieGenre genre; + + private String thumbnail; + + @Column(name = "running_time") + private int runningTime; + + @Column(name = "release_date") + private LocalDate releaseDate; + + @OneToMany(mappedBy = "movie") + private Set showtimes = new HashSet<>(); + + @OneToMany(mappedBy = "movie") + private Set movieTheaters = new HashSet<>(); +} \ No newline at end of file diff --git a/infrastructure/src/main/java/com/example/app/movie/out/persistence/entity/MovieTheaterEntity.java b/infrastructure/src/main/java/com/example/app/movie/out/persistence/entity/MovieTheaterEntity.java new file mode 100644 index 000000000..6db649f28 --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/movie/out/persistence/entity/MovieTheaterEntity.java @@ -0,0 +1,28 @@ +package com.example.app.movie.out.persistence.entity; + +import com.example.app.common.BaseEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name="tb_movie_theater_rel") +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class MovieTheaterEntity extends BaseEntity { + + @Id + @Column(name = "movie_theater_rel_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "movie_id") + private MovieEntity movie; + + @ManyToOne + @JoinColumn(name = "theater_id") + private TheaterEntity theater; +} diff --git a/infrastructure/src/main/java/com/example/app/movie/out/persistence/entity/ShowtimeEntity.java b/infrastructure/src/main/java/com/example/app/movie/out/persistence/entity/ShowtimeEntity.java new file mode 100644 index 000000000..df94240d8 --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/movie/out/persistence/entity/ShowtimeEntity.java @@ -0,0 +1,34 @@ +package com.example.app.movie.out.persistence.entity; + +import com.example.app.common.BaseEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalTime; + +@Entity +@Table(name="tb_movie_showtime") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ShowtimeEntity extends BaseEntity { + + @Id + @Column(name = "showtime_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "`start`") + private LocalTime start; + + @Column(name = "`end`") + private LocalTime end; + + @ManyToOne + @JoinColumn(name = "movie_id") + private MovieEntity movie; +} diff --git a/infrastructure/src/main/java/com/example/app/movie/out/persistence/entity/TheaterEntity.java b/infrastructure/src/main/java/com/example/app/movie/out/persistence/entity/TheaterEntity.java new file mode 100644 index 000000000..de40cc53e --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/movie/out/persistence/entity/TheaterEntity.java @@ -0,0 +1,30 @@ +package com.example.app.movie.out.persistence.entity; + +import com.example.app.common.BaseEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.HashSet; +import java.util.Set; + +@Entity +@Table(name="tb_theater") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class TheaterEntity extends BaseEntity { + + @Id + @Column(name = "theater_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + @OneToMany(mappedBy = "theater") + private Set movieTheaters = new HashSet<>(); +} diff --git a/infrastructure/src/main/java/com/example/app/movie/out/persistence/mapper/MovieMapper.java b/infrastructure/src/main/java/com/example/app/movie/out/persistence/mapper/MovieMapper.java new file mode 100644 index 000000000..97b01cfd6 --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/movie/out/persistence/mapper/MovieMapper.java @@ -0,0 +1,48 @@ +package com.example.app.movie.out.persistence.mapper; + +import com.example.app.movie.domain.Movie; +import com.example.app.movie.domain.Showtime; +import com.example.app.movie.domain.Theater; +import com.example.app.movie.out.persistence.entity.MovieEntity; +import com.example.app.movie.out.persistence.entity.MovieTheaterEntity; +import com.example.app.movie.out.persistence.entity.ShowtimeEntity; +import org.mapstruct.Mapper; + + +@Mapper(componentModel = "spring") +public abstract class MovieMapper { + + public Movie movieEntityToMovie(MovieEntity movieEntity) { + var showtimes = movieEntity.getShowtimes() + .stream() + .map(this::showtimeJpaEntityToShowtime) + .toList(); + + var theaters = movieEntity.getMovieTheaters() + .stream() + .map(this::movieTheaterJpaEntityToTheater) + .toList(); + + return Movie.builder() + .id(movieEntity.getId()) + .title(movieEntity.getTitle()) + .description(movieEntity.getDescription()) + .status(movieEntity.getStatus()) + .rating(movieEntity.getRating()) + .genre(movieEntity.getGenre()) + .thumbnail(movieEntity.getThumbnail()) + .runningTime(movieEntity.getRunningTime()) + .releaseDate(movieEntity.getReleaseDate()) + .showtimes(showtimes) + .theaters(theaters) + .build(); + } + + private Showtime showtimeJpaEntityToShowtime(ShowtimeEntity showtimeEntity) { + return new Showtime(showtimeEntity.getStart(), showtimeEntity.getEnd()); + } + + private Theater movieTheaterJpaEntityToTheater(MovieTheaterEntity movieTheaterJpaEntity) { + return new Theater(movieTheaterJpaEntity.getTheater().getName()); + } +} diff --git a/infrastructure/src/main/java/com/example/app/movie/out/persistence/repository/MovieRepository.java b/infrastructure/src/main/java/com/example/app/movie/out/persistence/repository/MovieRepository.java new file mode 100644 index 000000000..23c96e7f1 --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/movie/out/persistence/repository/MovieRepository.java @@ -0,0 +1,7 @@ +package com.example.app.movie.out.persistence.repository; + +import com.example.app.movie.out.persistence.entity.MovieEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MovieRepository extends JpaRepository, MovieRepositoryCustom { +} diff --git a/infrastructure/src/main/java/com/example/app/movie/out/persistence/repository/MovieRepositoryCustom.java b/infrastructure/src/main/java/com/example/app/movie/out/persistence/repository/MovieRepositoryCustom.java new file mode 100644 index 000000000..e99a1d28d --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/movie/out/persistence/repository/MovieRepositoryCustom.java @@ -0,0 +1,11 @@ +package com.example.app.movie.out.persistence.repository; + +import com.example.app.movie.out.persistence.entity.MovieEntity; +import com.querydsl.core.types.Predicate; + +import java.util.List; + +public interface MovieRepositoryCustom { + + List findAllBy(Predicate predicate); +} diff --git a/infrastructure/src/main/java/com/example/app/movie/out/persistence/repository/MovieRepositoryCustomImpl.java b/infrastructure/src/main/java/com/example/app/movie/out/persistence/repository/MovieRepositoryCustomImpl.java new file mode 100644 index 000000000..75a90eab8 --- /dev/null +++ b/infrastructure/src/main/java/com/example/app/movie/out/persistence/repository/MovieRepositoryCustomImpl.java @@ -0,0 +1,31 @@ +package com.example.app.movie.out.persistence.repository; + +import com.example.app.movie.out.persistence.entity.MovieEntity; +import com.querydsl.core.types.Predicate; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +import static com.example.app.movie.out.persistence.entity.QMovieEntity.movieEntity; +import static com.example.app.movie.out.persistence.entity.QMovieTheaterEntity.movieTheaterEntity; +import static com.example.app.movie.out.persistence.entity.QShowtimeEntity.showtimeEntity; + +@RequiredArgsConstructor +public class MovieRepositoryCustomImpl implements MovieRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List findAllBy(Predicate predicate) { + return queryFactory + .select(movieEntity) + .from(movieEntity) + .leftJoin(movieEntity.showtimes, showtimeEntity).fetchJoin() + .leftJoin(movieEntity.movieTheaters, movieTheaterEntity).fetchJoin() + .leftJoin(movieTheaterEntity.theater).fetchJoin() + .where(predicate) + .orderBy(movieEntity.releaseDate.desc()) + .fetch(); + } +} diff --git a/infrastructure/src/test/java/com/example/TestApplication.java b/infrastructure/src/test/java/com/example/TestApplication.java new file mode 100644 index 000000000..e0ed55e30 --- /dev/null +++ b/infrastructure/src/test/java/com/example/TestApplication.java @@ -0,0 +1,9 @@ +package com.example; + +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@SpringBootApplication +@EnableJpaAuditing +public class TestApplication { +} diff --git a/infrastructure/src/test/java/com/example/app/booking/out/persistence/repository/BookingRepositoryTest.java b/infrastructure/src/test/java/com/example/app/booking/out/persistence/repository/BookingRepositoryTest.java new file mode 100644 index 000000000..1851e3280 --- /dev/null +++ b/infrastructure/src/test/java/com/example/app/booking/out/persistence/repository/BookingRepositoryTest.java @@ -0,0 +1,73 @@ +package com.example.app.booking.out.persistence.repository; + +import com.example.app.booking.out.persistence.entity.BookingEntity; +import com.example.app.config.QuerydslConfig; +import com.navercorp.fixturemonkey.FixtureMonkey; +import com.navercorp.fixturemonkey.api.introspector.ConstructorPropertiesArbitraryIntrospector; +import com.navercorp.fixturemonkey.jakarta.validation.plugin.JakartaValidationPlugin; +import com.querydsl.core.types.ExpressionUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import java.time.LocalDate; +import java.util.stream.Stream; + +import static com.example.app.booking.out.persistence.entity.QBookingEntity.bookingEntity; +import static com.navercorp.fixturemonkey.api.instantiator.Instantiator.constructor; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@DataJpaTest +@Import({QuerydslConfig.class}) +public class BookingRepositoryTest { + + private FixtureMonkey fixtureMonkey; + + @Autowired + private BookingRepository sut; + + @BeforeEach + void setUp() { + fixtureMonkey = FixtureMonkey.builder() + .objectIntrospector(ConstructorPropertiesArbitraryIntrospector.INSTANCE) + .plugin(new JakartaValidationPlugin()) + .build(); + } + + @Test + public void save_findAllBy_테스트() { + var booking1 = fixtureMonkey.giveMeBuilder(BookingEntity.class) + .instantiate(constructor() + .parameter(long.class) + .parameter(long.class) + .parameter(long.class, "movieId") + .parameter(long.class) + .parameter(LocalDate.class) + .parameter(long.class) + .parameter(int.class)) + .set("movieId", 1L) + .sampleList(3); + + var booking2 = fixtureMonkey.giveMeBuilder(BookingEntity.class) + .instantiate(constructor() + .parameter(long.class) + .parameter(long.class) + .parameter(long.class, "movieId") + .parameter(long.class) + .parameter(LocalDate.class) + .parameter(long.class) + .parameter(int.class)) + .set("movieId", 2L) + .sampleList(4); + + sut.saveAll(Stream.concat(booking1.stream(), booking2.stream()).toList()); + + var predicate = ExpressionUtils.allOf(bookingEntity.movieId.eq(1L)); + + var result = sut.findAllBy(predicate); + + assertEquals(3, result.size()); + } +} diff --git a/infrastructure/src/test/java/com/example/app/booking/out/persistence/repository/SeatRepositoryTest.java b/infrastructure/src/test/java/com/example/app/booking/out/persistence/repository/SeatRepositoryTest.java new file mode 100644 index 000000000..b178bfa1a --- /dev/null +++ b/infrastructure/src/test/java/com/example/app/booking/out/persistence/repository/SeatRepositoryTest.java @@ -0,0 +1,65 @@ +package com.example.app.booking.out.persistence.repository; + +import com.example.app.booking.out.persistence.entity.SeatEntity; +import com.example.app.config.QuerydslConfig; +import com.example.app.movie.type.TheaterSeat; +import com.navercorp.fixturemonkey.FixtureMonkey; +import com.navercorp.fixturemonkey.api.introspector.ConstructorPropertiesArbitraryIntrospector; +import com.navercorp.fixturemonkey.jakarta.validation.plugin.JakartaValidationPlugin; +import com.querydsl.core.types.ExpressionUtils; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import java.time.LocalDate; +import java.util.HashSet; + +import static com.example.app.booking.out.persistence.entity.QSeatEntity.seatEntity; +import static com.navercorp.fixturemonkey.api.instantiator.Instantiator.constructor; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@DataJpaTest +@Import({QuerydslConfig.class}) +public class SeatRepositoryTest { + + private FixtureMonkey fixtureMonkey; + + @Autowired + private SeatRepository sut; + + @BeforeEach + void setUp() { + fixtureMonkey = FixtureMonkey.builder() + .objectIntrospector(ConstructorPropertiesArbitraryIntrospector.INSTANCE) + .plugin(new JakartaValidationPlugin()) + .build(); + } + + @Test + public void save_findAllBy_테스트() { + var reservedSeats = new HashSet<>(fixtureMonkey.giveMeBuilder(SeatEntity.class) + .instantiate(constructor() + .parameter(long.class) + .parameter(long.class) + .parameter(long.class) + .parameter(long.class) + .parameter(long.class) + .parameter(LocalDate.class) + .parameter(TheaterSeat.class) + .parameter(boolean.class, "reserved") + .parameter(long.class, "version")) + .set("reserved", true) + .set("version", 1L) + .sampleList(10)); + + sut.saveAll(reservedSeats); + + var predicate = ExpressionUtils.allOf(seatEntity.reserved.isTrue()); + + var result = sut.findAllBy(predicate); + + assertEquals(10, result.size()); + } +} diff --git a/infrastructure/src/test/java/com/example/app/movie/out/persistence/repository/MovieRepositoryTest.java b/infrastructure/src/test/java/com/example/app/movie/out/persistence/repository/MovieRepositoryTest.java new file mode 100644 index 000000000..84b28ad2b --- /dev/null +++ b/infrastructure/src/test/java/com/example/app/movie/out/persistence/repository/MovieRepositoryTest.java @@ -0,0 +1,93 @@ +package com.example.app.movie.out.persistence.repository; + +import com.example.app.config.QuerydslConfig; +import com.example.app.movie.out.persistence.entity.MovieEntity; +import com.example.app.movie.out.persistence.entity.MovieTheaterEntity; +import com.example.app.movie.out.persistence.entity.ShowtimeEntity; +import com.example.app.movie.type.MovieGenre; +import com.example.app.movie.type.MovieRating; +import com.example.app.movie.type.MovieStatus; +import com.navercorp.fixturemonkey.FixtureMonkey; +import com.navercorp.fixturemonkey.api.introspector.ConstructorPropertiesArbitraryIntrospector; +import com.navercorp.fixturemonkey.api.type.TypeReference; +import com.navercorp.fixturemonkey.jakarta.validation.plugin.JakartaValidationPlugin; +import com.querydsl.core.types.ExpressionUtils; +import net.jqwik.api.Arbitraries; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import java.time.LocalDate; +import java.util.Set; +import java.util.stream.Stream; + +import static com.example.app.movie.out.persistence.entity.QMovieEntity.movieEntity; +import static com.navercorp.fixturemonkey.api.instantiator.Instantiator.constructor; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@DataJpaTest +@Import({QuerydslConfig.class}) +public class MovieRepositoryTest { + + private FixtureMonkey fixtureMonkey; + + @Autowired + private MovieRepository sut; + + @BeforeEach + void setUp() { + fixtureMonkey = FixtureMonkey.builder() + .objectIntrospector(ConstructorPropertiesArbitraryIntrospector.INSTANCE) + .plugin(new JakartaValidationPlugin()) + .build(); + } + + @Test + public void save_findAllBy_테스트() { + var movies1 = fixtureMonkey.giveMeBuilder(MovieEntity.class) + .instantiate(constructor() + .parameter(long.class) + .parameter(String.class, "title") + .parameter(String.class) + .parameter(MovieStatus.class) + .parameter(MovieRating.class) + .parameter(MovieGenre.class) + .parameter(String.class) + .parameter(int.class) + .parameter(LocalDate.class) + .parameter(new TypeReference>() {}, "showtimes") + .parameter(new TypeReference>() {}, "movieTheaters")) + .set("title", "탑건:"+Arbitraries.strings()) + .set("showtimes", null) + .set("movieTheaters", null) + .sampleList(2); + + var movies2 = fixtureMonkey.giveMeBuilder(MovieEntity.class) + .instantiate(constructor() + .parameter(long.class) + .parameter(String.class, "title") + .parameter(String.class) + .parameter(MovieStatus.class) + .parameter(MovieRating.class) + .parameter(MovieGenre.class) + .parameter(String.class) + .parameter(int.class) + .parameter(LocalDate.class) + .parameter(new TypeReference>() {}, "showtimes") + .parameter(new TypeReference>() {}, "movieTheaters")) + .set("title", "myMovie") + .set("showtimes", null) + .set("movieTheaters", null) + .sampleList(5); + + sut.saveAll(Stream.concat(movies1.stream(), movies2.stream()).toList()); + + var predicate = ExpressionUtils.allOf(movieEntity.title.contains("탑건")); + + var result = sut.findAllBy(predicate); + + assertEquals(2, result.size()); + } +} diff --git a/infrastructure/src/test/resources/application-test.yml b/infrastructure/src/test/resources/application-test.yml new file mode 100644 index 000000000..77349edd9 --- /dev/null +++ b/infrastructure/src/test/resources/application-test.yml @@ -0,0 +1,15 @@ +spring: + datasource : + url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driverClassName: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect + show-sql: true + data: + redis: + host: localhost + port: 6379 \ No newline at end of file diff --git a/movie-api/.gitignore b/movie-api/.gitignore new file mode 100644 index 000000000..c31ae9fd0 --- /dev/null +++ b/movie-api/.gitignore @@ -0,0 +1,36 @@ +../.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ \ No newline at end of file diff --git a/movie-api/build.gradle b/movie-api/build.gradle new file mode 100644 index 000000000..dcb32232f --- /dev/null +++ b/movie-api/build.gradle @@ -0,0 +1,36 @@ +dependencies { + implementation project(':domain') + implementation project(':infrastructure') + implementation project(':application') +} + +bootJar { + enabled = true +} + +test { + useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + + reports { + html.required = true + } +} + +jacocoTestCoverageVerification { + dependsOn jacocoTestReport + + violationRules { + rule { + element = 'PACKAGE' + includes = ['com.example.app.movie.presentation.controller.*'] + limit { + maximum = 0.80 + } + } + } +} \ No newline at end of file diff --git a/movie-api/src/main/java/com/example/MovieApiApplication.java b/movie-api/src/main/java/com/example/MovieApiApplication.java new file mode 100644 index 000000000..c40b79234 --- /dev/null +++ b/movie-api/src/main/java/com/example/MovieApiApplication.java @@ -0,0 +1,12 @@ +package com.example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class MovieApiApplication { + + public static void main(String[] args) { + SpringApplication.run(MovieApiApplication.class, args); + } +} diff --git a/movie-api/src/main/java/com/example/app/movie/presentation/controller/MovieController.java b/movie-api/src/main/java/com/example/app/movie/presentation/controller/MovieController.java new file mode 100644 index 000000000..59591f450 --- /dev/null +++ b/movie-api/src/main/java/com/example/app/movie/presentation/controller/MovieController.java @@ -0,0 +1,42 @@ +package com.example.app.movie.presentation.controller; + +import com.example.app.common.annotation.ClientIp; +import com.example.app.movie.presentation.dto.request.MovieSearchRequest; +import com.example.app.movie.presentation.dto.response.MovieResponse; +import com.example.app.movie.presentation.service.RateLimitService; +import com.example.app.movie.presentation.service.RedisRateLimitService; +import com.example.app.movie.usecase.SearchMovieUseCase; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.concurrent.ExecutionException; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1") +public class MovieController { + + private final SearchMovieUseCase searchMovieUseCase; + private final RateLimitService rateLimitService; + private final RedisRateLimitService redisRateLimitService; + + @GetMapping("/movies") + public ResponseEntity> searchMovies( + @Valid MovieSearchRequest movieSearchRequest, + @ClientIp String clientIp) { + + redisRateLimitService.checkAccessLimit(clientIp); + + var data = searchMovieUseCase.searchMovies(movieSearchRequest.toMovieSearchCommand()) + .stream() + .map(MovieResponse::toResponse) + .toList(); + + return ResponseEntity.ok(data); + } +} diff --git a/movie-api/src/main/java/com/example/app/movie/presentation/dto/request/MovieSearchRequest.java b/movie-api/src/main/java/com/example/app/movie/presentation/dto/request/MovieSearchRequest.java new file mode 100644 index 000000000..fdf0bfb4b --- /dev/null +++ b/movie-api/src/main/java/com/example/app/movie/presentation/dto/request/MovieSearchRequest.java @@ -0,0 +1,22 @@ +package com.example.app.movie.presentation.dto.request; + +import com.example.app.common.annotation.ValidEnum; +import com.example.app.movie.dto.SearchMovieCommand; +import com.example.app.movie.type.MovieGenre; +import org.hibernate.validator.constraints.Length; + +import static java.util.Objects.nonNull; + +public record MovieSearchRequest( + @Length(max = 255, message = "제목은 255자 이하로 검색해주세요") + String title, + @ValidEnum(enumClass = MovieGenre.class) + String genre +){ + public SearchMovieCommand toMovieSearchCommand() { + return SearchMovieCommand.builder() + .title(this.title) + .genre(nonNull(this.genre) ? MovieGenre.valueOf(this.genre) : null) + .build(); + } +} \ No newline at end of file diff --git a/movie-api/src/main/java/com/example/app/movie/presentation/dto/response/MovieResponse.java b/movie-api/src/main/java/com/example/app/movie/presentation/dto/response/MovieResponse.java new file mode 100644 index 000000000..44216dff0 --- /dev/null +++ b/movie-api/src/main/java/com/example/app/movie/presentation/dto/response/MovieResponse.java @@ -0,0 +1,55 @@ +package com.example.app.movie.presentation.dto.response; + +import com.example.app.movie.domain.Movie; +import com.example.app.movie.domain.Showtime; +import com.example.app.movie.domain.Theater; +import lombok.Builder; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Comparator; +import java.util.List; + +@Builder +public record MovieResponse( + Long id, + String title, + String description, + String status, + String rating, + String genre, + String thumbnail, + int runningTime, + LocalDate releaseDate, + List showtimes, + List theaters +){ + public static MovieResponse toResponse(Movie movie) { + var showtimes = movie.showtimes() + .stream() + .sorted(Comparator.comparing(Showtime::start)) + .map(showtime -> String.format("%s ~ %s", + showtime.start().format(DateTimeFormatter.ofPattern("HH:mm")), + showtime.end().format(DateTimeFormatter.ofPattern("HH:mm")))) + .toList(); + + var theaters = movie.theaters() + .stream() + .map(Theater::name) + .toList(); + + return MovieResponse.builder() + .id(movie.id()) + .title(movie.title()) + .description(movie.description()) + .status(movie.status().getDescription()) + .rating(movie.rating().getDescription()) + .genre(movie.genre().getDescription()) + .thumbnail(movie.thumbnail()) + .runningTime(movie.runningTime()) + .releaseDate(movie.releaseDate()) + .showtimes(showtimes) + .theaters(theaters) + .build(); + } +} diff --git a/movie-api/src/main/java/com/example/app/movie/presentation/service/RateLimitService.java b/movie-api/src/main/java/com/example/app/movie/presentation/service/RateLimitService.java new file mode 100644 index 000000000..3df919b73 --- /dev/null +++ b/movie-api/src/main/java/com/example/app/movie/presentation/service/RateLimitService.java @@ -0,0 +1,53 @@ +package com.example.app.movie.presentation.service; + +import com.example.app.common.exception.RateLimitException; +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import com.google.common.util.concurrent.RateLimiter; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +@SuppressWarnings("UnstableApiUsage") +@Service +@RequiredArgsConstructor +public class RateLimitService { + + private final Cache rateLimiters = CacheBuilder.newBuilder() + .expireAfterAccess(1, TimeUnit.HOURS) + .build(); + + private final Cache blockedIps = CacheBuilder.newBuilder() + .expireAfterWrite(1, TimeUnit.HOURS) + .build(); + + private final Cache requestCounts = CacheBuilder.newBuilder() + .expireAfterWrite(1, TimeUnit.MINUTES) + .build(); + + public void checkAccessLimit(String clientIp) throws ExecutionException { + var isBlocked = blockedIps.getIfPresent(clientIp) != null; + + if (isBlocked) { + throw new RateLimitException(); + } + + RateLimiter rateLimiter = rateLimiters.get(clientIp, () -> RateLimiter.create(1.0)); + + AtomicInteger count = requestCounts.get(clientIp, () -> new AtomicInteger(0)); + int currentCount = count.incrementAndGet(); + + if (currentCount >= 50) { + blockedIps.put(clientIp, LocalDateTime.now()); + throw new RateLimitException(); + } + + if (!rateLimiter.tryAcquire()) { + throw new RateLimitException(); + } + } +} diff --git a/movie-api/src/main/java/com/example/app/movie/presentation/service/RedisRateLimitService.java b/movie-api/src/main/java/com/example/app/movie/presentation/service/RedisRateLimitService.java new file mode 100644 index 000000000..86d43e75a --- /dev/null +++ b/movie-api/src/main/java/com/example/app/movie/presentation/service/RedisRateLimitService.java @@ -0,0 +1,48 @@ +package com.example.app.movie.presentation.service; + +import com.example.app.common.exception.RateLimitException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class RedisRateLimitService { + + private final RedisTemplate redisTemplate; + private final static String PREFIX_REQUEST = "MOVIE:REQ:"; + private final static String PREFIX_BLOCK = "MOVIE:BLOCK:"; + + public void checkAccessLimit(String clientIp) { + var requestKey = PREFIX_REQUEST + clientIp; + var blockKey = PREFIX_BLOCK + clientIp; + + var isBlocked = redisTemplate.hasKey(blockKey); + + if (Boolean.TRUE.equals(isBlocked)) { + throw new RateLimitException(); + } + + var requestCount = redisTemplate.execute(new DefaultRedisScript() { + { + setScriptText(""" + local count = redis.call('INCR', KEYS[1]) + if count == 1 then + redis.call('EXPIRE', KEYS[1], 60) + end + return count + """); + setResultType(Long.class); + } + }, Collections.singletonList(requestKey)); + + if (requestCount != null && requestCount >= 50) { + redisTemplate.opsForValue().set(blockKey, "1", 1, TimeUnit.HOURS); + throw new RateLimitException(); + } + } +} diff --git a/movie-api/src/main/resources/application.yml b/movie-api/src/main/resources/application.yml new file mode 100644 index 000000000..9fe5ab555 --- /dev/null +++ b/movie-api/src/main/resources/application.yml @@ -0,0 +1,22 @@ +spring: + application: + name: api + datasource: + url: jdbc:mysql://localhost:3306/my?useUnicode=true&characterEncoding=utf8&autoReconnect=true&serverTimezone=Asia/Seoul + username: root + password: 1234 + driver-class-name: com.mysql.cj.jdbc.Driver + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.MySQLDialect + data: + redis: + host: localhost + port: 6379 + +server: + servlet: + context-path: /api \ No newline at end of file diff --git a/movie-api/src/test/java/com/example/MovieApiApplicationTests.java b/movie-api/src/test/java/com/example/MovieApiApplicationTests.java new file mode 100644 index 000000000..34fe07c1e --- /dev/null +++ b/movie-api/src/test/java/com/example/MovieApiApplicationTests.java @@ -0,0 +1,7 @@ +package com.example; + +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +public class MovieApiApplicationTests { +} diff --git a/movie-api/src/test/java/com/example/app/movie/presentation/config/EmbeddedRedisConfig.java b/movie-api/src/test/java/com/example/app/movie/presentation/config/EmbeddedRedisConfig.java new file mode 100644 index 000000000..27357b940 --- /dev/null +++ b/movie-api/src/test/java/com/example/app/movie/presentation/config/EmbeddedRedisConfig.java @@ -0,0 +1,29 @@ +package com.example.app.movie.presentation.config; + +import jakarta.annotation.PreDestroy; +import jakarta.annotation.PostConstruct; +import org.springframework.boot.autoconfigure.data.redis.RedisProperties; +import org.springframework.boot.test.context.TestConfiguration; +import redis.embedded.RedisServer; + +import java.io.IOException; + +@TestConfiguration +public class EmbeddedRedisConfig { + + private final RedisServer redisServer; + + public EmbeddedRedisConfig(RedisProperties redisProperties) throws IOException { + this.redisServer = new RedisServer(redisProperties.getPort()); + } + + @PostConstruct + public void postConstruct() throws IOException { + redisServer.start(); + } + + @PreDestroy + public void preDestroy() throws IOException { + redisServer.stop(); + } +} diff --git a/movie-api/src/test/java/com/example/app/movie/presentation/controller/MovieControllerTest.java b/movie-api/src/test/java/com/example/app/movie/presentation/controller/MovieControllerTest.java new file mode 100644 index 000000000..e1e19f900 --- /dev/null +++ b/movie-api/src/test/java/com/example/app/movie/presentation/controller/MovieControllerTest.java @@ -0,0 +1,43 @@ +package com.example.app.movie.presentation.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.example.app.common.exception.RateLimitException; +import com.example.app.movie.presentation.config.EmbeddedRedisConfig; +import com.example.app.movie.presentation.dto.request.MovieSearchRequest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.jdbc.Sql; + +@SpringBootTest(classes = EmbeddedRedisConfig.class) +@TestPropertySource(properties = "spring.config.location = classpath:application-test.yml") +@Sql(scripts = "/movie-data.sql", executionPhase = Sql.ExecutionPhase.BEFORE_TEST_CLASS) +public class MovieControllerTest { + + @Autowired + private MovieController sut; + + @Test + public void 영화_리스트_검색() { + var searchRequest = new MovieSearchRequest("탑건", "ACTION"); + var response = sut.searchMovies(searchRequest, "127.0.0.1"); + var movies = response.getBody(); + + assertEquals(200, response.getStatusCode().value()); + assertEquals(movies.size(), 2); + } + + @Test + public void Rate_Limit_1분_50요청_테스트() { + var searchRequest = new MovieSearchRequest("탑건", "ACTION"); + + assertThrows(RateLimitException.class, () -> { + for (int i=0; i < 50; i++) { + sut.searchMovies(searchRequest, "127.0.0.1"); + } + }); + } +} diff --git a/movie-api/src/test/resources/application-test.yml b/movie-api/src/test/resources/application-test.yml new file mode 100644 index 000000000..77349edd9 --- /dev/null +++ b/movie-api/src/test/resources/application-test.yml @@ -0,0 +1,15 @@ +spring: + datasource : + url: jdbc:h2:mem:test;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driverClassName: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect + show-sql: true + data: + redis: + host: localhost + port: 6379 \ No newline at end of file diff --git a/movie-api/src/test/resources/movie-data.sql b/movie-api/src/test/resources/movie-data.sql new file mode 100644 index 000000000..fb8112d39 --- /dev/null +++ b/movie-api/src/test/resources/movie-data.sql @@ -0,0 +1,36 @@ +INSERT INTO `tb_movie` (`movie_id`,`title`,`description`,`status`,`rating`,`genre`,`thumbnail`,`running_time`,`release_date`,`updated_at`,`created_at`) VALUES (1,'나 홀로 집에','크리스마스 휴가 당일, 늦잠을 자 정신없이 공항으로 출발한 맥콜리스터 가족은 전날 부린 말썽에 대한 벌로 다락방에 들어가 있던 8살 케빈을 깜박 잊고 프랑스로 떠나버린다. 매일 형제들에게 치이며 가족이 전부 없어졌으면 좋겠다고 생각한 케빈은 갑자기 찾아온 자유를 만끽한다.','SHOWING','ALL_AGES','COMEDY','https://m.media-amazon.com/images/M/MV5BNzNmNmQ2ZDEtMTc1MS00NjNiLThlMGUtZmQxNTg1Nzg5NWMzXkEyXkFqcGc@._V1_QL75_UX190_CR0,1,190,281_.jpg',103,'1991-07-06','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie` (`movie_id`,`title`,`description`,`status`,`rating`,`genre`,`thumbnail`,`running_time`,`release_date`,`updated_at`,`created_at`) VALUES (2,'탑건','최고의 파일럿들만이 갈 수 있는 캘리포니아의 한 비행 조종 학교 탑건에서의 사나이들의 우정과 사랑의 모험이 시작된다. 자신을 좇는 과거의 기억과 경쟁자, 그리고 사랑 사이에서 고군분투하는 그의 여정이 펼쳐진다.','SHOWING','FIFTEEN_ABOVE','ACTION','https://m.media-amazon.com/images/M/MV5BZmVjNzQ3MjYtYTZiNC00Y2YzLWExZTEtMTM2ZDllNDI0MzgyXkEyXkFqcGc@._V1_QL75_UX190_CR0,6,190,281_.jpg',109,'1986-05-12','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie` (`movie_id`,`title`,`description`,`status`,`rating`,`genre`,`thumbnail`,`running_time`,`release_date`,`updated_at`,`created_at`) VALUES (3,'탑건:메버릭','해군 최고의 비행사 피트 미첼은 비행 훈련소에서 갓 졸업을 한 신입 비행사들 팀의 훈련을 맡게 된다. 자신을 좇는 과거의 기억과 위험천만한 임무 속에서 고군분투하는 그의 비상이 펼쳐진다.','SHOWING','TWELVE_ABOVE','ACTION','https://m.media-amazon.com/images/M/MV5BMDBkZDNjMWEtOTdmMi00NmExLTg5MmMtNTFlYTJlNWY5YTdmXkEyXkFqcGc@._V1_QL75_UX190_CR0,0,190,281_.jpg',130,'2022-05-18','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie` (`movie_id`,`title`,`description`,`status`,`rating`,`genre`,`thumbnail`,`running_time`,`release_date`,`updated_at`,`created_at`) VALUES (4,'하얼빈','1908년 함경북도 신아산에서 안중근이 이끄는 독립군들은 일본군과의 전투에서 큰 승리를 거둔다. 대한의군 참모중장 안중근은 만국공법에 따라 전쟁포로인 일본인들을 풀어주게 되고, 이 사건으로 인해 독립군 사이에서는 안중근에 대한 의심과 함께 균열이 일기 시작한다.','SCHEDULED','FIFTEEN_ABOVE','ACTION','https://m.media-amazon.com/images/M/MV5BNmY4YzM5NzUtMTg4Yy00Yzc3LThlZDktODk4YjljZmNlODA0XkEyXkFqcGc@._V1_QL75_UY281_CR4,0,190,281_.jpg',113,'2024-12-24','2025-01-09 00:00:00','2025-01-09 00:00:00'); + +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (1,1,'08:00:00','09:45:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (2,1,'10:00:00','11:45:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (3,1,'13:00:00','14:45:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (4,1,'15:30:00','17:15:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (5,2,'10:30:00','12:15:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (6,2,'14:30:00','16:15:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (7,3,'11:30:00','14:15:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (8,3,'15:40:00','17:25:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (9,3,'18:50:00','20:45:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (10,3,'07:30:00','09:50:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_showtime` (`showtime_id`,`movie_id`,"start","end",`updated_at`,`created_at`) VALUES (11,4,'11:10:00','13:05:00','2025-01-09 00:00:00','2025-01-09 00:00:00'); + +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (1,'강남점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (2,'강북점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (3,'봉천점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (4,'안양점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (5,'평촌점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (6,'인덕원점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (7,'사당점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (8,'삼성점','2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_theater` (`theater_id`,`name`,`updated_at`,`created_at`) VALUES (9,'신림점','2025-01-09 00:00:00','2025-01-09 00:00:00'); + +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (1,1,1,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (2,2,3,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (3,3,4,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (4,1,4,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (5,1,5,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (6,2,5,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (7,2,1,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (8,3,2,'2025-01-09 00:00:00','2025-01-09 00:00:00'); +INSERT INTO `tb_movie_theater_rel` (`movie_theater_rel_id`,`movie_id`,`theater_id`,`updated_at`,`created_at`) VALUES (9,4,8,'2025-01-09 00:00:00','2025-01-09 00:00:00'); \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..1713f3b0a --- /dev/null +++ b/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = 'movie-application' +include 'domain', 'infrastructure', 'application', 'movie-api', 'booking-api' \ No newline at end of file