diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..8af972cde --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..519a738be --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +HELP.md +.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/ + +.env +**/src/main/resources/data.sql \ No newline at end of file diff --git a/README.md b/README.md index 5fcc66b4d..83c83986f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,420 @@ ## [본 과정] 이커머스 핵심 프로세스 구현 [단기 스킬업 Redis 교육 과정](https://hh-skillup.oopy.io/) 을 통해 상품 조회 및 주문 과정을 구현하며 현업에서 발생하는 문제를 Redis의 핵심 기술을 통해 해결합니다. > Indexing, Caching을 통한 성능 개선 / 단계별 락 구현을 통한 동시성 이슈 해결 (낙관적/비관적 락, 분산락 등) + +# 1. 1주차 쿼리 +## 쿼리 +다음은 현재 상영중인 영화 조회 API의 쿼리이다. + +```sql +"select m from Movie m where m.releaseDate < NOW()" + +"select new org.example.dto.ScreenInfoProjection(sr.name, ss.startTime, ss.endTime) " + + "from ScreenSchedule ss " + + "join Movie m on m.id = ss.movieId " + + "join ScreenRoom sr on sr.id = ss.screenRoomId " + + "where m.id = :movieId" +``` + +## 실행 계획 +``` +DAU = 100; +averageConnectionsPerUser = 2; // 사용자당 평균 접속 수 +totalRequestsPerDay = 100 * 2 = 200; // 하루 총 요청 수 +averageRPS = 200 / 86400 = 0.002; // 평균 RPS +peakRPS = 0.002 * 10 = 0.02; // 최대 RPS +``` +- 최대 TPS = 0.02 +- VU = 100으로 부하테스트 돌렸을 때 최대 TPS가 0.02까지 나오는지 확인 할 예정이다. + +```jsx +export const options = { + stages: [ + { duration: '5m', target: 100 } // 5분 동안 사용자 수 100명 증가시킨다 + ], + thresholds: { + http_req_duration: ['p(95)<200'], // 95%의 요청이 200ms 이내에 응답해야 함 + http_req_failed: ['rate<0.01'], // 실패율은 1% 미만이어야 함 + }, +} + +export default function() { + let res = http.get('http://localhost:8080/movies/playing'); + check(res, { + 'is status 200': (r) => r.status === 200, + }); + sleep(1); +} +``` +- 위 스크립트로 k6 부하테스트를 실행 할 예정이다. + +## 부하 테스트 결과 (스크린샷) +![img.png](img.png) +- p(95)의 응답 소요 시간이 4.66s로 실행 계획 때 설정했던 임계값(200ms)을 넘는다. +- 실패율은 0% 이다. + +![img_1.png](img_1.png) +- 1초당 최대 처리량은 16.2개이다. + +# 2. 검색 기능 추가 (index 적용 전) +```java +@Query("select new org.example.dto.MovieScreeningInfo" + + "(m.id, m.title, m.thumbnail, m.genre, m.ageRating, m.releaseDate, m.runningTime, sr.name, ss.startTime, ss.endTime) " + + "from Movie m " + + "join ScreenSchedule ss on m.id = ss.movieId " + + "join ScreenRoom sr on ss.screenRoomId = sr.id " + + "where (:title IS NULL OR m.title LIKE %:title%) " + + "AND (:genre IS NULL OR m.genre = :genre) " + + "And (m.isPlaying = :isPlaying)") +List findScreeningInfos(@Param("title") String title, @Param("genre") Genre genre, @Param("isPlaying") boolean isPlaying); +``` + +## 실행 계획 + +``` +DAU = 1000; +averageConnectionsPerUser = 2; // 사용자당 평균 접속 수 +totalRequestsPerDay = 1000 * 2 = 2000; // 하루 총 요청 수 +averageRPS = 2000 / 86400 = 0.02; // 평균 RPS +peakRPS = 0.01 * 10 = 0.2; // 최대 RPS +``` +- 최대 TPS = 0.2 +- VU = 1000 부하테스트 돌렸을 때 최대 TPS가 0.2까지 나오는지 확인 할 예정이다. + +```jsx +export const options = { + stages: [ + { duration: '5m', target: 1000 } // 5분 동안 사용자 수 1000명 증가시킨다 + ], + thresholds: { + http_req_duration: ['p(95)<200'], // 95%의 요청이 200ms 이내에 응답해야 함 + http_req_failed: ['rate<0.01'], // 실패율은 1% 미만이어야 함 + }, +} + +export default function() { + let res = http.get('http://localhost:8080/movies/playing?movieTitle=A&genre=SF&playing=true'); + check(res, { + 'is status 200': (r) => r.status === 200, + }); + sleep(1); +} +``` +- 위 스크립트로 k6 부하테스트를 실행 할 예정이다. + +## 부하 테스트 결과 (스크린샷) +### 검색 필터 적용한 데이터 조회 + +![img_2.png](img_2.png) +- p(95)의 응답 소요 시간이 380ms로 실행 계획 때 설정했던 임계값(200ms)을 넘는다. +- 실패율은 0% 이다. + +![img_3.png](img_3.png) +- 최대 TPS는 808.9 이다. + +# 3. 검색 기능 추가 (index 적용 후) +## 적용한 인덱스 DDL + +```sql +create index title_genre_isPlaying_idx on movie (genre, title); + +create index title_idx on movie (title); +``` + +## 쿼리 + +### LIKE 연산 + +```java +@Query("select new org.example.dto.MovieScreeningInfo" + + "(m.id, m.title, m.thumbnail, m.genre, m.ageRating, m.releaseDate, m.runningTime, sr.name, ss.startTime, ss.endTime) " + + "from Movie m " + + "join ScreenSchedule ss on m.id = ss.movieId " + + "join ScreenRoom sr on ss.screenRoomId = sr.id " + + "where (:title IS NULL OR m.title LIKE %:title%) " + + "AND (:genre IS NULL OR m.genre = :genre) " + + "And (m.isPlaying = :isPlaying)") +List findScreeningInfos(@Param("title") String title, @Param("genre") Genre genre, @Param("isPlaying") boolean isPlaying); +``` + +### 동등 연산 + +```java +@Query("select new org.example.dto.MovieScreeningInfo" + + "(m.id, m.title, m.thumbnail, m.genre, m.ageRating, m.releaseDate, m.runningTime, sr.name, ss.startTime, ss.endTime) " + + "from Movie m " + + "join ScreenSchedule ss on m.id = ss.movieId " + + "join ScreenRoom sr on ss.screenRoomId = sr.id " + + "where (:title IS NULL OR m.title=:title) " + + "AND (:genre IS NULL OR m.genre = :genre) " + + "And (m.isPlaying = :isPlaying)") +List findScreeningInfos(@Param("title") String title, @Param("genre") Genre genre, @Param("isPlaying") boolean isPlaying); +``` + +## 실행 계획 + +### LIKE 연산 +``` +DAU = 1000; +averageConnectionsPerUser = 2; // 사용자당 평균 접속 수 +totalRequestsPerDay = 1000 * 2 = 2000; // 하루 총 요청 수 +averageRPS = 2000 / 86400 = 0.02; // 평균 RPS +peakRPS = 0.01 * 10 = 0.2; // 최대 RPS +``` +- 최대 TPS = 0.2 +- VU = 1000 부하테스트 돌렸을 때 최대 TPS가 0.2까지 나오는지 확인 할 예정이다. + +```jsx +export const options = { + stages: [ + { duration: '5m', target: 1000 } // 5분 동안 사용자 수 1000명 증가시킨다 + ], + thresholds: { + http_req_duration: ['p(95)<200'], // 95%의 요청이 200ms 이내에 응답해야 함 + http_req_failed: ['rate<0.01'], // 실패율은 1% 미만이어야 함 + }, +} + +export default function() { + let res = http.get('http://localhost:8080/movies/playing?movieTitle=A&genre=SF&playing=true'); + check(res, { + 'is status 200': (r) => r.status === 200, + }); + sleep(1); +} +``` +- 위 스크립트로 k6 부하테스트를 실행 할 예정이다. + +### 동등 연산 + +``` +DAU = 1000; +averageConnectionsPerUser = 2; // 사용자당 평균 접속 수 +totalRequestsPerDay = 1000 * 2 = 2000; // 하루 총 요청 수 +averageRPS = 2000 / 86400 = 0.02; // 평균 RPS +peakRPS = 0.01 * 10 = 0.2; // 최대 RPS +``` +- 최대 TPS = 0.2 +- VU = 1000 부하테스트 돌렸을 때 최대 TPS가 0.2까지 나오는지 확인 할 예정이다. + +```jsx +export const options = { + stages: [ + { duration: '5m', target: 1000 } // 5분 동안 사용자 수 1000명 증가시킨다 + ], + thresholds: { + http_req_duration: ['p(95)<200'], // 95%의 요청이 200ms 이내에 응답해야 함 + http_req_failed: ['rate<0.01'], // 실패율은 1% 미만이어야 함 + }, +} + +export default function() { + let res = http.get('http://localhost:8080/movies/playing?movieTitle=A&genre=SF&playing=true'); + check(res, { + 'is status 200': (r) => r.status === 200, + }); + sleep(1); +} +``` +- 위 스크립트로 k6 부하테스트를 실행 할 예정이다. + +## 부하 테스트 결과 (스크린샷) + +### LIKE 연산 +![img_4.png](img_4.png) +- VU = 1000으로 했을 때 p(95)의 응답 소요 시간이 94.13ms로 실행 계획 때 설정했던 임계값(200ms)을 넘지 않는다. +- 실패율은 0% 이다. + +![img_5.png](img_5.png) +- VU = 1500으로 했을 때는 p(95)의 응답 소요 시간이 553.3ms로 실행 계획 때 설정했던 임계값(200ms)을 넘는다. +- 실패율은 0% 이다. + +![img_6.png](img_6.png) +- 최대 TPS는 1.18K 이다. + +### 동등 연산 +![img_8.png](img_8.png) +- VU = 1000으로 했을 때 p(95)의 응답 소요 시간이 178.37ms로 실행 계획 때 설정했던 임계값(200ms)을 넘지 않는다. +- 실패율은 0% 이다. + +![img_9.png](img_9.png) +- VU = 2000으로 했을 때 p(95)의 응답 소요 시간이 396.7ms로 실행 계획 때 설정했던 임계값(200ms)을 넘는다. +- 실패율은 0% 이다. + +![img_11.png](img_11.png) +- 최대 TPS는 1.6K이다. + +# 4. 로컬 Caching 적용 후 + +## 캐싱한 데이터의 종류 +PlayingMoviesResponseDto 리스트를 캐싱했다. + +``` +캐시 이름: playingMovies +캐시 키: InceptionACTIONtrue +캐싱된 값: +[ + { + "title": "Inception", + "thumbnail": "https://example.com/inception_thumbnail.jpg", + "genre": "ACTION", + "ageRating": "PG-13", + "releaseDate": "2010-07-16", + "runningTime": 148, + "screeningInfos": [ + { + "screenRoomName": "Room 1", + "screeningTimeInfos": [ + {"startTime": "2023-01-18T12:00:00", "endTime": "2023-01-18T14:28:00"}, + {"startTime": "2023-01-18T15:00:00", "endTime": "2023-01-18T17:28:00"} + ] + }, + { + "screenRoomName": "Room 2", + "screeningTimeInfos": [ + {"startTime": "2023-01-18T18:00:00", "endTime": "2023-01-18T20:28:00"} + ] + } + ] + } +] +``` + +## 실행 계획 + +``` +DAU = 5000; +averageConnectionsPerUser = 2; // 사용자당 평균 접속 수 +totalRequestsPerDay = 5000 * 2 = 10000; // 하루 총 요청 수 +averageRPS = 10000 / 86400 = 0.1; // 평균 RPS +peakRPS = 0.1 * 10 = 1; // 최대 RPS +``` +- 최대 TPS = 1 +- VU = 5000 부하테스트 돌렸을 때 최대 TPS가 1까지 나오는지 확인 할 예정이다. + +```jsx +export const options = { + stages: [ + { duration: '5m', target: 5000 } // 5분 동안 사용자 수 5000명 증가시킨다 + ], + thresholds: { + http_req_duration: ['p(95)<200'], // 95%의 요청이 200ms 이내에 응답해야 함 + http_req_failed: ['rate<0.01'], // 실패율은 1% 미만이어야 함 + }, +} + +export default function() { + let res = http.get('http://localhost:8080/movies/playing?movieTitle=A&genre=SF&playing=true'); + check(res, { + 'is status 200': (r) => r.status === 200, + }); + sleep(1); +} +``` +- 위 스크립트로 k6 부하테스트를 실행 할 예정이다. + +## 부하 테스트 결과 (스크린샷) +![img_13.png](img_13.png) +- VU = 5000으로 했을 때 p(95)의 응답 소요 시간이 54.95ms로 실행 계획 때 설정했던 임계값(200ms)을 넘지 않는다. +- 실패율은 0% 이다. + +![img_12.png](img_12.png) +- VU = 8000으로 했을 때 p(95)의 응답 소요 시간이 795.16ms로 실행 계획 때 설정했던 임계값(200ms)을 넘는다. +- 실패율은 0% 이다. + +![img_14.png](img_14.png) +- 최대 TPS는 5.32K 이다. + +# 5. 분산 Caching 적용 후 + +## 캐싱한 데이터의 종류 +PlayingMoviesResponseDto 리스트를 캐싱했다. + +``` +캐시 이름: playingMovies +캐시 키: InceptionACTIONtrue +캐싱된 값: +[ + { + "title": "Inception", + "thumbnail": "https://example.com/inception_thumbnail.jpg", + "genre": "ACTION", + "ageRating": "PG-13", + "releaseDate": "2010-07-16", + "runningTime": 148, + "screeningInfos": [ + { + "screenRoomName": "Room 1", + "screeningTimeInfos": [ + {"startTime": "2023-01-18T12:00:00", "endTime": "2023-01-18T14:28:00"}, + {"startTime": "2023-01-18T15:00:00", "endTime": "2023-01-18T17:28:00"} + ] + }, + { + "screenRoomName": "Room 2", + "screeningTimeInfos": [ + {"startTime": "2023-01-18T18:00:00", "endTime": "2023-01-18T20:28:00"} + ] + } + ] + } +] +``` + +## 실행 계획 + +``` +DAU = 5000; +averageConnectionsPerUser = 2; // 사용자당 평균 접속 수 +totalRequestsPerDay = 5000 * 2 = 10000; // 하루 총 요청 수 +averageRPS = 10000 / 86400 = 0.1; // 평균 RPS +peakRPS = 0.1 * 10 = 1; // 최대 RPS +``` +- 최대 TPS = 1 +- VU = 5000 부하테스트 돌렸을 때 최대 TPS가 1까지 나오는지 확인 할 예정이다. + +```jsx +export const options = { + stages: [ + { duration: '5m', target: 5000 } // 5분 동안 사용자 수 5000명 증가시킨다 + ], + thresholds: { + http_req_duration: ['p(95)<200'], // 95%의 요청이 200ms 이내에 응답해야 함 + http_req_failed: ['rate<0.01'], // 실패율은 1% 미만이어야 함 + }, +} + +export default function() { + let res = http.get('http://localhost:8080/movies/playing?movieTitle=A&genre=SF&playing=true'); + check(res, { + 'is status 200': (r) => r.status === 200, + }); + sleep(1); +} +``` +- 위 스크립트로 k6 부하테스트를 실행 할 예정이다. + +## 부하 테스트 결과 (스크린샷) +![img_18.png](img_18.png) +- VU = 5000으로 했을 때 p(95)의 응답 소요 시간이 451.92ms로 실행 계획 때 설정했던 임계값(200ms)을 넘는다. +- 실패율은 0% 이다. + +![img_20.png](img_20.png) +- 최대 TPS는 4.13K 이다. + +# 6. leaseTime, waitTime +``` +waitTime: 1초, leaseTime: 5초 +``` +**waitTime** +- 분산 캐싱 적용 후, 평균 처리 시간이 약 450ms가 소요되었으므로 넉넉하게 1초로 설정했습니다. + +**leaseTime** +- leaseTime은 가장 오래 걸린 처리 시간에 영향을 받으므로 waitTime보다 길게 5초로 설정했습니다. +너무 길지 않게 설정해서 다른 요청들이 불필요하게 대기하지 않도록 하였습니다. + + +# 7. 테스트 커버리지 결과 +![img_7.png](img_7.png) + +![img_10.png](img_10.png) diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..c26451aaf --- /dev/null +++ b/build.gradle @@ -0,0 +1,95 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.1.5' + id 'io.spring.dependency-management' version '1.1.3' + id 'jacoco' +} + +bootJar.enabled = false // 빌드시 현재 모듈(multi-module)의 .jar를 생성하지 않습니다. + +repositories { + mavenCentral() +} + +subprojects { // 모든 하위 모듈들에 이 설정을 적용합니다. + group 'org.example' + version '0.0.1-SNAPSHOT' + sourceCompatibility = '17' + + apply plugin: 'java' + apply plugin: 'java-library' + apply plugin: 'org.springframework.boot' + apply plugin: 'io.spring.dependency-management' + apply plugin: 'jacoco' + + configurations { + compileOnly { + extendsFrom annotationProcessor + } + } + + repositories { + mavenCentral() + } + + dependencies { + // Test + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + // JPA + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + + // Web + implementation 'org.springframework.boot:spring-boot-starter-web' + + // MySql + runtimeOnly 'mysql:mysql-connector-java:8.0.28' // mysql8 + + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // Caffeine + implementation 'org.springframework.boot:spring-boot-starter-cache' + implementation 'com.github.ben-manes.caffeine:caffeine:3.1.7' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + + // Reddison + implementation 'org.redisson:redisson-spring-boot-starter:3.22.1' + + // queryDSL + implementation("com.querydsl:querydsl-jpa:5.0.0:jakarta") + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + + annotationProcessor("jakarta.persistence:jakarta.persistence-api") + annotationProcessor("jakarta.annotation:jakarta.annotation-api") + + // validation + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Guava Rate Limiter + implementation("com.google.guava:guava:31.1-jre") + } + + jacoco { + toolVersion = "0.8.10" + } + + jacocoTestReport { + dependsOn test + + reports { + xml.required = false + csv.required = false + html.required = true // HTML 리포트 생성 + } + } + + test { + useJUnitPlatform() + finalizedBy jacocoTestReport + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..5d7be08d7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,27 @@ +services: + db: + image: mysql + restart: unless-stopped + env_file: + - .env + ports: + - "3305:3306" + volumes: + - ./mysql/conf.d:/etc/mysql/conf.d + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_general_ci + networks: + - test_network + + redis: + image: redis:7.0 + container_name: redis-cache + restart: unless-stopped + ports: + - "6378:6379" + networks: + - test_network + +networks: + test_network: \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..a4b76b953 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..e2847c820 --- /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.11.1-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..f5feea6d6 --- /dev/null +++ b/gradlew @@ -0,0 +1,252 @@ +#!/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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# 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/platforms/jvm/plugins-application/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 -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || 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..9d21a2183 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@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 +@rem SPDX-License-Identifier: Apache-2.0 +@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. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +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/img.png b/img.png new file mode 100644 index 000000000..c6c45a324 Binary files /dev/null and b/img.png differ diff --git a/img_1.png b/img_1.png new file mode 100644 index 000000000..e172a1201 Binary files /dev/null and b/img_1.png differ diff --git a/img_10.png b/img_10.png new file mode 100644 index 000000000..01206a142 Binary files /dev/null and b/img_10.png differ diff --git a/img_11.png b/img_11.png new file mode 100644 index 000000000..3c5e763f2 Binary files /dev/null and b/img_11.png differ diff --git a/img_12.png b/img_12.png new file mode 100644 index 000000000..f37ca5fd0 Binary files /dev/null and b/img_12.png differ diff --git a/img_13.png b/img_13.png new file mode 100644 index 000000000..88a06d89b Binary files /dev/null and b/img_13.png differ diff --git a/img_14.png b/img_14.png new file mode 100644 index 000000000..2d2c4faba Binary files /dev/null and b/img_14.png differ diff --git a/img_18.png b/img_18.png new file mode 100644 index 000000000..7ea4e4949 Binary files /dev/null and b/img_18.png differ diff --git a/img_2.png b/img_2.png new file mode 100644 index 000000000..fa73006cd Binary files /dev/null and b/img_2.png differ diff --git a/img_20.png b/img_20.png new file mode 100644 index 000000000..21da410de Binary files /dev/null and b/img_20.png differ diff --git a/img_3.png b/img_3.png new file mode 100644 index 000000000..28fccef16 Binary files /dev/null and b/img_3.png differ diff --git a/img_4.png b/img_4.png new file mode 100644 index 000000000..0f2da73ed Binary files /dev/null and b/img_4.png differ diff --git a/img_5.png b/img_5.png new file mode 100644 index 000000000..00d0b729b Binary files /dev/null and b/img_5.png differ diff --git a/img_6.png b/img_6.png new file mode 100644 index 000000000..1d6e7de1e Binary files /dev/null and b/img_6.png differ diff --git a/img_7.png b/img_7.png new file mode 100644 index 000000000..e354079d2 Binary files /dev/null and b/img_7.png differ diff --git a/img_8.png b/img_8.png new file mode 100644 index 000000000..46b0133b9 Binary files /dev/null and b/img_8.png differ diff --git a/img_9.png b/img_9.png new file mode 100644 index 000000000..c56c8745d Binary files /dev/null and b/img_9.png differ diff --git a/module-api/build.gradle b/module-api/build.gradle new file mode 100644 index 000000000..de839b1b9 --- /dev/null +++ b/module-api/build.gradle @@ -0,0 +1,36 @@ +plugins { + id 'java' + id 'jacoco' +} + +bootJar.enabled = true + +dependencies { + implementation project(':module-common') + implementation project(':module-domain') + + // Bean Validation + implementation 'org.springframework.boot:spring-boot-starter-validation' + + testImplementation platform('org.junit:junit-bom:5.9.1') + testImplementation 'org.junit.jupiter:junit-jupiter' +} + +jacoco { + toolVersion = "0.8.10" +} + +jacocoTestReport { + dependsOn test + + reports { + xml.required = false + csv.required = false + html.required = true + } +} + +test { + useJUnitPlatform() + finalizedBy jacocoTestReport +} \ No newline at end of file diff --git a/module-api/src/main/java/org/example/ApiApplication.java b/module-api/src/main/java/org/example/ApiApplication.java new file mode 100644 index 000000000..eaa096814 --- /dev/null +++ b/module-api/src/main/java/org/example/ApiApplication.java @@ -0,0 +1,16 @@ +package org.example; + +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@SpringBootApplication(scanBasePackages = "org.example") +public class ApiApplication { + + public static void main(String[] args) { + SpringApplication.run(ApiApplication.class, args); + } +} diff --git a/module-api/src/main/java/org/example/controller/MovieController.java b/module-api/src/main/java/org/example/controller/MovieController.java new file mode 100644 index 000000000..775b851c8 --- /dev/null +++ b/module-api/src/main/java/org/example/controller/MovieController.java @@ -0,0 +1,55 @@ +package org.example.controller; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.baseresponse.BaseResponse; +import org.example.dto.request.MoviesFilterRequestDto; +import org.example.dto.response.PlayingMoviesResponseDto; +import org.example.exception.RateLimitExceededException; +import org.example.service.RedisRateLimiterService; +import org.example.service.movie.MovieService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +import static org.example.baseresponse.BaseResponseStatus.TOO_MANY_REQUEST_ERROR; + +@Slf4j +@RestController +@RequiredArgsConstructor +public class MovieController { + private final MovieService movieService; + private final RedisRateLimiterService redisRateLimiterService; + + @GetMapping("/movies/playing") + public BaseResponse> getPlayingMovies(@ModelAttribute @Validated MoviesFilterRequestDto moviesFilterRequestDto, HttpServletRequest request) { + String clientIp = getClientIP(request); + + // IP 차단 여부 확인 + Long allowed = redisRateLimiterService.isAllowed(clientIp); + if (allowed == -1) { + throw new RateLimitExceededException(TOO_MANY_REQUEST_ERROR); + } + + List playingMovies = movieService.getPlayingMovies(moviesFilterRequestDto); + return new BaseResponse<>(playingMovies); + } + + private String getClientIP(HttpServletRequest request) { + String ip = request.getHeader("X-Forwarded-For"); + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("Proxy-Client-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getHeader("WL-Proxy-Client-IP"); + } + if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { + ip = request.getRemoteAddr(); + } + return ip; + } +} diff --git a/module-api/src/main/java/org/example/controller/ReservationController.java b/module-api/src/main/java/org/example/controller/ReservationController.java new file mode 100644 index 000000000..20df2b1d2 --- /dev/null +++ b/module-api/src/main/java/org/example/controller/ReservationController.java @@ -0,0 +1,25 @@ +package org.example.controller; + +import lombok.RequiredArgsConstructor; +import org.example.baseresponse.BaseResponse; +import org.example.baseresponse.BaseResponseStatus; +import org.example.dto.request.ReservationRequestDto; +import org.example.service.reservation.ReservationService; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import static org.example.baseresponse.BaseResponseStatus.SUCCESS; + +@RestController +@RequiredArgsConstructor +public class ReservationController { + private final ReservationService reservationService; + + @PostMapping("/reservation") + public BaseResponse getPlayingMovies(@RequestBody @Validated ReservationRequestDto reservationRequestDto) { + reservationService.reserveMovie(reservationRequestDto); + return new BaseResponse<>(SUCCESS); + } +} diff --git a/module-api/src/main/java/org/example/dto/request/MoviesFilterRequestDto.java b/module-api/src/main/java/org/example/dto/request/MoviesFilterRequestDto.java new file mode 100644 index 000000000..dc7de2c4f --- /dev/null +++ b/module-api/src/main/java/org/example/dto/request/MoviesFilterRequestDto.java @@ -0,0 +1,19 @@ +package org.example.dto.request; + +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import org.example.annotaion.ValidEnum; +import org.example.domain.movie.Genre; + +@Setter +@Getter +@AllArgsConstructor +public class MoviesFilterRequestDto { + @Size(max = 255) + private String movieTitle; + + @ValidEnum(enumClass = Genre.class, message = "장르는 다음 중 하나여야 합니다: ACTION, ROMANCE, HORROR, SF") + private String genre; +} diff --git a/module-api/src/main/java/org/example/dto/request/ReservationRequestDto.java b/module-api/src/main/java/org/example/dto/request/ReservationRequestDto.java new file mode 100644 index 000000000..a6ffd2c08 --- /dev/null +++ b/module-api/src/main/java/org/example/dto/request/ReservationRequestDto.java @@ -0,0 +1,12 @@ +package org.example.dto.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; + +import java.util.List; + +public record ReservationRequestDto( + @NotNull Long usersId, + @NotNull Long screenScheduleId, + @NotNull @Valid List reservationSeats +) {} diff --git a/module-api/src/main/java/org/example/dto/request/ReservationSeatDto.java b/module-api/src/main/java/org/example/dto/request/ReservationSeatDto.java new file mode 100644 index 000000000..db4bebfbe --- /dev/null +++ b/module-api/src/main/java/org/example/dto/request/ReservationSeatDto.java @@ -0,0 +1,15 @@ +package org.example.dto.request; + +import jakarta.validation.constraints.NotNull; +import org.example.annotaion.ValidEnum; +import org.example.domain.seat.Col; +import org.example.domain.seat.Row; + +public record ReservationSeatDto( + @NotNull + @ValidEnum(enumClass = Row.class, message = "올바른 행 이름을 입력해주세요.") + String row, + @NotNull + @ValidEnum(enumClass = Col.class, message = "올바른 열 이름을 입력해주세요.") + String col +) {} diff --git a/module-api/src/main/java/org/example/dto/response/FoundMovieScreeningInfoList.java b/module-api/src/main/java/org/example/dto/response/FoundMovieScreeningInfoList.java new file mode 100644 index 000000000..8def5bc85 --- /dev/null +++ b/module-api/src/main/java/org/example/dto/response/FoundMovieScreeningInfoList.java @@ -0,0 +1,15 @@ +package org.example.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.example.dto.MovieScreeningInfo; + +import java.util.List; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class FoundMovieScreeningInfoList { + private List movieScreeningInfos; +} diff --git a/module-api/src/main/java/org/example/dto/response/PlayingMoviesResponseDto.java b/module-api/src/main/java/org/example/dto/response/PlayingMoviesResponseDto.java new file mode 100644 index 000000000..c4f1cbc47 --- /dev/null +++ b/module-api/src/main/java/org/example/dto/response/PlayingMoviesResponseDto.java @@ -0,0 +1,23 @@ +package org.example.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.example.domain.movie.AgeRating; +import org.example.domain.movie.Genre; + +import java.io.Serializable; +import java.time.LocalDate; +import java.util.List; + +@AllArgsConstructor +@Getter +public class PlayingMoviesResponseDto implements Serializable { + private String title; + private String thumbnail; + private Genre genre; + private AgeRating ageRating; + private LocalDate releaseDate; + private int runningTime; + private List screeningInfos; +} diff --git a/module-api/src/main/java/org/example/dto/response/ScreeningInfo.java b/module-api/src/main/java/org/example/dto/response/ScreeningInfo.java new file mode 100644 index 000000000..5c2aa9894 --- /dev/null +++ b/module-api/src/main/java/org/example/dto/response/ScreeningInfo.java @@ -0,0 +1,20 @@ +package org.example.dto.response; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) +@JsonSubTypes({ + @JsonSubTypes.Type(PlayingMoviesResponseDto.class) +}) +@Getter +@AllArgsConstructor +public class ScreeningInfo { + private String screenRoomName; + private List screeningTimeInfos; +} diff --git a/module-api/src/main/java/org/example/dto/response/ScreeningTimeInfo.java b/module-api/src/main/java/org/example/dto/response/ScreeningTimeInfo.java new file mode 100644 index 000000000..169899803 --- /dev/null +++ b/module-api/src/main/java/org/example/dto/response/ScreeningTimeInfo.java @@ -0,0 +1,20 @@ +package org.example.dto.response; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) +@JsonSubTypes({ + @JsonSubTypes.Type(ScreeningInfo.class) +}) +@Getter +@AllArgsConstructor +public class ScreeningTimeInfo { + private LocalDateTime startTime; + private LocalDateTime endTime; +} diff --git a/module-api/src/main/java/org/example/service/RedisRateLimiterService.java b/module-api/src/main/java/org/example/service/RedisRateLimiterService.java new file mode 100644 index 000000000..7021cedbf --- /dev/null +++ b/module-api/src/main/java/org/example/service/RedisRateLimiterService.java @@ -0,0 +1,43 @@ +package org.example.service; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.stereotype.Service; + +import java.util.Collections; + +@Slf4j +@Service +@AllArgsConstructor +public class RedisRateLimiterService { + private final StringRedisTemplate redisTemplate; + + private static final String LUA_SCRIPT = + "local ip = KEYS[1]\n" + + "local blockKey = 'block:' .. ip\n" + + "local requestKey = 'request:' .. ip\n" + + + "if redis.call('EXISTS', blockKey) == 1 then\n" + + " return -1\n" + + "end\n" + + + "local currentCount = tonumber(redis.call('GET', requestKey) or '0')\n" + + "redis.call('SET', requestKey, currentCount) \n" + + + "if currentCount >= tonumber('50') then\n" + + " redis.call('SETEX', blockKey, 3600, 'BLOCKED')\n" + + " redis.call('DEL', requestKey)\n" + + " return -1\n" + + "end\n" + + + "currentCount = redis.call('INCR', requestKey)\n" + + + "return currentCount"; + + public Long isAllowed(String ip) { + Long result = redisTemplate.execute(RedisScript.of(LUA_SCRIPT, Long.class), Collections.singletonList(ip)); + return result; + } +} diff --git a/module-api/src/main/java/org/example/service/movie/FindMovieService.java b/module-api/src/main/java/org/example/service/movie/FindMovieService.java new file mode 100644 index 000000000..fe5416780 --- /dev/null +++ b/module-api/src/main/java/org/example/service/movie/FindMovieService.java @@ -0,0 +1,33 @@ +package org.example.service.movie; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.domain.movie.Genre; +import org.example.dto.MovieScreeningInfo; +import org.example.dto.request.MoviesFilterRequestDto; +import org.example.dto.response.FoundMovieScreeningInfoList; +import org.example.repository.MovieJpaRepository; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FindMovieService { + private final MovieJpaRepository movieJpaRepository; + + @Cacheable(value = "playingMovies", + key = "(#moviesFilterRequestDto.genre != null ? #moviesFilterRequestDto.genre : 'ALL') + true") + public FoundMovieScreeningInfoList getPlayingMovies(MoviesFilterRequestDto moviesFilterRequestDto) { + Genre genre = null; + if (moviesFilterRequestDto.getGenre() != null) { + genre = Genre.valueOf(moviesFilterRequestDto.getGenre()); + } + + List movieScreeningInfos + = movieJpaRepository.findScreeningInfos(moviesFilterRequestDto.getMovieTitle(), genre); + return new FoundMovieScreeningInfoList(movieScreeningInfos); + } +} diff --git a/module-api/src/main/java/org/example/service/movie/MovieService.java b/module-api/src/main/java/org/example/service/movie/MovieService.java new file mode 100644 index 000000000..b0d595eb4 --- /dev/null +++ b/module-api/src/main/java/org/example/service/movie/MovieService.java @@ -0,0 +1,72 @@ +package org.example.service.movie; + +import com.google.common.util.concurrent.RateLimiter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.dto.MovieScreeningInfo; +import org.example.dto.request.MoviesFilterRequestDto; +import org.example.dto.response.PlayingMoviesResponseDto; +import org.example.dto.response.ScreeningInfo; +import org.example.dto.response.ScreeningTimeInfo; +import org.springframework.stereotype.Service; + +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MovieService { + private final FindMovieService findMovieService; + + // 초당 2개의 요청을 허용 + public final RateLimiter rateLimiter; + + public List getPlayingMovies(MoviesFilterRequestDto moviesFilterRequestDto) { +// if (!rateLimiter.tryAcquire()) { +// throw new RateLimitExceededException(TOO_MANY_REQUEST_ERROR); +// } + + List movieScreeningInfos = + findMovieService.getPlayingMovies(moviesFilterRequestDto).getMovieScreeningInfos() + .stream() + .filter(movieScreeningInfo -> + Optional.ofNullable(movieScreeningInfo.getTitle()).orElse("") + .contains(Optional.ofNullable(moviesFilterRequestDto.getMovieTitle()).orElse(""))) + .toList(); + + Map movieInfoMap = new HashMap<>(); + + for (MovieScreeningInfo movieScreeningInfo : movieScreeningInfos) { + PlayingMoviesResponseDto playingMoviesResponseDto = getPlayingMoviesResponseDto(movieScreeningInfo, movieInfoMap, movieScreeningInfo.getMovieId()); + ScreeningInfo screeningInfo = getScreeningInfo(movieScreeningInfo, playingMoviesResponseDto); + screeningInfo.getScreeningTimeInfos().add( + new ScreeningTimeInfo(movieScreeningInfo.getStartTime(), movieScreeningInfo.getEndTIme()) + ); + } + + return new ArrayList<>(movieInfoMap.values()); + } + + private PlayingMoviesResponseDto getPlayingMoviesResponseDto(MovieScreeningInfo movieScreeningInfo, Map movieInfoMap, Long movieId) { + return movieInfoMap.computeIfAbsent(movieId, t -> new PlayingMoviesResponseDto( + movieScreeningInfo.getTitle(), + movieScreeningInfo.getThumbnail(), + movieScreeningInfo.getGenre(), + movieScreeningInfo.getAgeRating(), + movieScreeningInfo.getReleaseDate(), + movieScreeningInfo.getRunningTime(), + new ArrayList<>() + )); + } + + private ScreeningInfo getScreeningInfo(MovieScreeningInfo movieScreeningInfo, PlayingMoviesResponseDto playingMoviesResponseDto) { + return playingMoviesResponseDto.getScreeningInfos().stream() + .filter(i -> i.getScreenRoomName().equals(movieScreeningInfo.getScreenRoomName())) + .findFirst() + .orElseGet(() -> { + ScreeningInfo newInfo = new ScreeningInfo(movieScreeningInfo.getScreenRoomName(), new ArrayList<>()); + playingMoviesResponseDto.getScreeningInfos().add(newInfo); + return newInfo; + }); + } +} \ No newline at end of file diff --git a/module-api/src/main/java/org/example/service/reservation/ReservationService.java b/module-api/src/main/java/org/example/service/reservation/ReservationService.java new file mode 100644 index 000000000..eadf18b96 --- /dev/null +++ b/module-api/src/main/java/org/example/service/reservation/ReservationService.java @@ -0,0 +1,90 @@ +package org.example.service.reservation; + +import lombok.AllArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.config.RedissonLockUtil; +import org.example.domain.reservationseat.ReservationSeat; +import org.example.domain.seat.Col; +import org.example.domain.seat.Row; +import org.example.domain.seat.Seat; +import org.example.dto.SeatsDto; +import org.example.dto.request.ReservationRequestDto; +import org.example.exception.SeatException; +import org.example.repository.ReservationSeatRepository; +import org.example.repository.ScreenScheduleJpaRepository; +import org.example.repository.SeatJpaRepository; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +import static org.example.baseresponse.BaseResponseStatus.UNAVAILABLE_SEAT_ERROR; + +@Slf4j +@Service +@AllArgsConstructor +public class ReservationService { + private final ReservationSeatRepository reservationSeatRepository; + private final ScreenScheduleJpaRepository screenScheduleJpaRepository; + private final RedissonLockUtil redissonLockUtil; + private final SaveReservationService saveReservationService; + private final SeatJpaRepository seatJpaRepository; + + public void reserveMovie(ReservationRequestDto reservationRequestDto) { + List reservationSeats = new ArrayList<>( + reservationRequestDto.reservationSeats().stream() + .map(seat -> new SeatsDto(Row.valueOf(seat.row()), Col.valueOf(seat.col()))) + .toList() + ); + + Long screenRoomId = screenScheduleJpaRepository.findScreenRoomIdById(reservationRequestDto.screenScheduleId()); + + // 예약하려는 좌석 검증 + validateSeats(reservationSeats); + + // 사용자가 동일한 상영에 대해 예약한 좌석 검증 + validateUserReserveSeats(reservationSeats, reservationRequestDto.usersId(), reservationRequestDto.screenScheduleId()); + + // 좌석들 반환 + List seats = validateReservedSeats(screenRoomId, reservationSeats); + + // 개별 좌석별 락 키 생성 + List lockKeys = reservationRequestDto.reservationSeats().stream() + .map(seat -> "lock:seat:" + reservationRequestDto.screenScheduleId() + ":" + seat.row() + ":" + seat.col()) + .toList(); + + // Redisson MultiLock 적용 (여러 개의 좌석을 동시에 보호) + redissonLockUtil.executeWithMultiLock(lockKeys, 1, 5, () -> { + saveReservationService.saveReservationWithTransaction(reservationRequestDto.usersId(), reservationRequestDto.screenScheduleId(), seats); + return null; + }); + } + + public List validateReservedSeats(Long screenRoomId, List reservationSeats) { + List seats = new ArrayList<>(); + for (SeatsDto reservationSeat : reservationSeats) { + Seat seat = seatJpaRepository.findSeats(screenRoomId, reservationSeat.getRow(), reservationSeat.getCol()) + .orElseThrow(() -> new SeatException(UNAVAILABLE_SEAT_ERROR)); + + seats.add(seat); + } + return seats; + } + + private void validateSeats(List seats) { + Seat.validateSeatCount(seats.size()); + Seat.validateContinuousSeats(seats); + } + + private void validateUserReserveSeats(List reservationSeats, Long userId, Long screenScheduleId) { + List reservedSeats = reservationSeatRepository.findReservedSeatByUserIdAndScreenScheduleId(userId, screenScheduleId); + if (reservedSeats.isEmpty()) { + return; + } + + ReservationSeat.validateCountExceeded(reservationSeats, reservedSeats); // 예약하려는 좌석이 5개 이상인지 + ReservationSeat.containsReservedSeat(reservationSeats, reservedSeats); // 이미 예약된 좌석과 겹치는지 + ReservationSeat.isSameRow(reservationSeats, reservedSeats); // 좌석이 같은 행에 있는지 + ReservationSeat.isContinuousCol(reservationSeats, reservedSeats); // 좌석이 연속된 열인지 + } +} \ No newline at end of file diff --git a/module-api/src/main/java/org/example/service/reservation/SaveReservationService.java b/module-api/src/main/java/org/example/service/reservation/SaveReservationService.java new file mode 100644 index 000000000..a08f4acbe --- /dev/null +++ b/module-api/src/main/java/org/example/service/reservation/SaveReservationService.java @@ -0,0 +1,53 @@ +package org.example.service.reservation; + +import jakarta.transaction.Transactional; +import lombok.AllArgsConstructor; +import org.example.domain.reservation.Reservation; +import org.example.domain.reservationseat.ReservationSeat; +import org.example.domain.seat.Seat; +import org.example.dto.SeatsDto; +import org.example.dto.request.ReservationRequestDto; +import org.example.exception.SeatException; +import org.example.repository.ReservationJpaRepository; +import org.example.repository.ReservationSeatRepository; +import org.example.repository.SeatJpaRepository; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +import static org.example.baseresponse.BaseResponseStatus.*; + +@Service +@AllArgsConstructor +public class SaveReservationService { + private final ReservationJpaRepository reservationJpaRepository; + private final ReservationSeatRepository reservationSeatRepository; + + @Transactional + public void saveReservationWithTransaction(Long userId, Long screenScheduleId, List seats) { + // 예약된 좌석인지 검증 + for (Seat seat : seats) { + boolean isReserved = reservationSeatRepository.findReservedSeatBySeatId(screenScheduleId, seat.getId()).isPresent(); + if (isReserved) { + throw new SeatException(CONCURRENT_RESERVATION_ERROR); + } + } + + Long reservationId = saveReservation(userId, screenScheduleId); + saveReservationSeats(seats, reservationId); + } + + private Long saveReservation(Long userId, Long screenScheduleId) { + Reservation reservation = Reservation.of(userId, screenScheduleId); + Reservation savedReservation = reservationJpaRepository.save(reservation); + return savedReservation.getId(); + } + + private void saveReservationSeats(List seats, Long reservationId) { + for (Seat seat : seats) { + ReservationSeat reservationSeat = ReservationSeat.of(reservationId, seat.getId()); + reservationSeatRepository.save(reservationSeat); + } + } +} diff --git a/module-api/src/main/resources/api-test.http b/module-api/src/main/resources/api-test.http new file mode 100644 index 000000000..6f4fd31a4 --- /dev/null +++ b/module-api/src/main/resources/api-test.http @@ -0,0 +1,2 @@ +GET http://localhost:8080/movies/playing +Accept: application/json \ No newline at end of file diff --git a/module-api/src/main/resources/application.yml b/module-api/src/main/resources/application.yml new file mode 100644 index 000000000..69ee4f8d5 --- /dev/null +++ b/module-api/src/main/resources/application.yml @@ -0,0 +1,29 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${DATASOURCE_URL} + username: ${DATASOURCE_USERNAME} + password: ${DATASOURCE_PASSWORD} + + jpa: + properties: + hibernate: + format_sql: true + show-sql: true + dialect: org.hibernate.dialect.MySQLDialect + hibernate: + ddl-auto: none + defer-datasource-initialization: true + + cache: + type: redis + + data: + redis: + host: ${DATA_REDIS_HOST} + port: ${DATA_REDIS_PORT} + + sql: + init: + mode: embedded + schema-locations: classpath:data.sql \ No newline at end of file diff --git a/module-api/src/main/resources/data.sql b/module-api/src/main/resources/data.sql new file mode 100644 index 000000000..dd76a08c7 --- /dev/null +++ b/module-api/src/main/resources/data.sql @@ -0,0 +1,635 @@ +INSERT INTO movie (title, thumbnail, genre, age_rating, release_date, running_time, is_playing) +VALUES + ('Inception', 'https://example.com/inception.jpg', 'SF', 'PG_13', '2010-07-16', 148, true), + ('The Dark Knight', 'https://example.com/dark_knight.jpg', 'ACTION', 'PG_13', '2008-07-18', 152, false), + ('Avengers: Endgame', 'https://example.com/endgame.jpg', 'ACTION', 'PG_13', '2019-04-26', 181, false), + ('La La Land', 'https://example.com/lalaland.jpg', 'ROMANCE', 'PG_13', '2016-12-09', 128, false), + ('The Matrix', 'https://example.com/matrix.jpg', 'SF', 'R', '1999-03-31', 136, false), + ('Titanic', 'https://example.com/titanic.jpg', 'ROMANCE', 'PG_13', '1997-12-19', 195, false), + ('Get Out', 'https://example.com/getout.jpg', 'HORROR', 'R', '2017-02-24', 104, false), + ('A Quiet Place', 'https://example.com/quietplace.jpg', 'HORROR', 'PG_13', '2018-04-06', 90, true), + ('It', 'https://example.com/it.jpg', 'HORROR', 'R', '2017-09-08', 135, false), + ('The Conjuring', 'https://example.com/conjuring.jpg', 'HORROR', 'R', '2013-07-19', 112, false), + ('Hereditary', 'https://example.com/hereditary.jpg', 'HORROR', 'R', '2018-06-08', 127, false), + ('Edge of Tomorrow', 'https://example.com/edgeoftomorrow.jpg', 'ACTION', 'PG_13', '2014-06-06', 113, false), + ('Mad Max: Fury Road', 'https://example.com/madmax.jpg', 'ACTION', 'R', '2015-05-15', 120, false), + ('Top Gun: Maverick', 'https://example.com/topgun.jpg', 'ACTION', 'PG_13', '2022-05-27', 131, true), + ('Gravity', 'https://example.com/gravity.jpg', 'SF', 'PG', '2013-10-04', 91, false), + ('Eternal Sunshine of the Spotless Mind', 'https://example.com/eternalsunshine.jpg', 'ROMANCE', 'R', '2004-03-19', 108, false), + ('The Notebook', 'https://example.com/thenotebook.jpg', 'ROMANCE', 'PG_13', '2004-06-25', 123, true), + ('Twilight', 'https://example.com/twilight.jpg', 'ROMANCE', 'PG_13', '2008-11-21', 122, false), + ('Interstellar', 'https://example.com/interstellar.jpg', 'SF', 'PG_13', '2014-11-07', 169, true), + ('The Matrix Reloaded', 'https://example.com/matrixreloaded.jpg', 'SF', 'R', '2003-05-15', 138, false), + ('The Matrix Revolutions', 'https://example.com/matrixrevolutions.jpg', 'SF', 'R', '2003-11-05', 129, true), + ('The Conjuring 2', 'https://example.com/conjuring2.jpg', 'HORROR', 'R', '2016-06-10', 134, false), + ('A Nightmare on Elm Street', 'https://example.com/nightmare.jpg', 'HORROR', 'R', '1984-11-09', 91, true), + ('It Chapter Two', 'https://example.com/itchaptertwo.jpg', 'HORROR', 'R', '2019-09-06', 169, false), + ('Her', 'https://example.com/her.jpg', 'ROMANCE', 'R', '2013-12-18', 126, true), + ('The Fault in Our Stars', 'https://example.com/faultinourstars.jpg', 'ROMANCE', 'PG_13', '2014-06-06', 126, false), + ('Looper', 'https://example.com/looper.jpg', 'SF', 'R', '2012-09-28', 113, true), + ('The Dark Knight Rises', 'https://example.com/darkknightrises.jpg', 'ACTION', 'PG_13', '2012-07-20', 165, false), + ('Avatar', 'https://example.com/avatar.jpg', 'SF', 'PG_13', '2009-12-18', 162, true), + ('Jurassic Park', 'https://example.com/jurassicpark.jpg', 'ACTION', 'PG_13', '1993-06-11', 127, false), + ('The Shining', 'https://example.com/shining.jpg', 'HORROR', 'R', '1980-05-23', 146, true), + ('Doctor Sleep', 'https://example.com/doctorsleep.jpg', 'HORROR', 'R', '2019-11-08', 152, false), + ('A Quiet Place Part II', 'https://example.com/quietplace2.jpg', 'HORROR', 'PG_13', '2021-05-28', 97, true), + ('The Invisible Man', 'https://example.com/invisibleman.jpg', 'HORROR', 'R', '2020-02-28', 124, false), + ('Ready Player One', 'https://example.com/readyplayerone.jpg', 'SF', 'PG_13', '2018-03-29', 140, true), + ('Star Wars: A New Hope', 'https://example.com/starwars.jpg', 'SF', 'PG', '1977-05-25', 121, false), + ('Rogue One: A Star Wars Story', 'https://example.com/rogueone.jpg', 'SF', 'PG_13', '2016-12-16', 133, true), + ('The Empire Strikes Back', 'https://example.com/empirestrikesback.jpg', 'SF', 'PG', '1980-05-21', 124, false), + ('Love Actually', 'https://example.com/loveactually.jpg', 'ROMANCE', 'R', '2003-11-14', 135, true), + ('The Proposal', 'https://example.com/theproposal.jpg', 'ROMANCE', 'PG_13', '2009-06-19', 108, false), + ('Crazy, Stupid, Love.', 'https://example.com/crazystupidlove.jpg', 'ROMANCE', 'PG_13', '2011-07-29', 118, true), + ('Fury', 'https://example.com/fury.jpg', 'ACTION', 'R', '2014-10-17', 134, false), + ('Dunkirk', 'https://example.com/dunkirk.jpg', 'ACTION', 'PG_13', '2017-07-21', 106, true), + ('The Bourne Ultimatum', 'https://example.com/bourneultimatum.jpg', 'ACTION', 'PG_13', '2007-08-03', 115, false), + ('The Avengers', 'https://example.com/avengers.jpg', 'ACTION', 'PG_13', '2012-05-04', 143, true), + ('Avengers: Infinity War', 'https://example.com/infinitywar.jpg', 'ACTION', 'PG_13', '2018-04-27', 149, false), + ('Black Panther', 'https://example.com/blackpanther.jpg', 'ACTION', 'PG_13', '2018-02-16', 134, true), + ('Captain Marvel', 'https://example.com/captainmarvel.jpg', 'ACTION', 'PG_13', '2019-03-08', 123, false), + ('Iron Man', 'https://example.com/ironman.jpg', 'ACTION', 'PG_13', '2008-05-02', 126, true), + ('Thor: Ragnarok', 'https://example.com/thorragnarok.jpg', 'ACTION', 'PG_13', '2017-11-03', 130, false), + ('Spider-Man: No Way Home', 'https://example.com/spidermannowayhome.jpg', 'ACTION', 'PG_13', '2021-12-17', 148, true), + ('Mission: Impossible - Fallout', 'https://example.com/missionfallout.jpg', 'ACTION', 'PG_13', '2018-07-27', 147, false), + ('Skyfall', 'https://example.com/skyfall.jpg', 'ACTION', 'PG_13', '2012-11-09', 143, true), + ('Casino Royale', 'https://example.com/casinoroyale.jpg', 'ACTION', 'PG_13', '2006-11-17', 144, false), + ('The Twilight Saga: Breaking Dawn', 'https://example.com/breakingdawn.jpg', 'ROMANCE', 'PG_13', '2011-11-18', 117, true), + ('The Kissing Booth', 'https://example.com/kissingbooth.jpg', 'ROMANCE', 'PG_13', '2018-05-11', 110, false), + ('Annabelle', 'https://example.com/annabelle.jpg', 'HORROR', 'R', '2014-10-03', 99, true), + ('The Nun', 'https://example.com/thenun.jpg', 'HORROR', 'R', '2018-09-07', 96, false), + ('Saw', 'https://example.com/saw.jpg', 'HORROR', 'NC_17', '2004-10-29', 103, true), + ('Saw II', 'https://example.com/saw2.jpg', 'HORROR', 'NC_17', '2005-10-28', 93, false), + ('Star Trek', 'https://example.com/startrek.jpg', 'SF', 'PG_13', '2009-05-08', 127, true), + ('Star Trek Into Darkness', 'https://example.com/startrekintodarkness.jpg', 'SF', 'PG_13', '2013-05-17', 132, false), + ('Star Trek Beyond', 'https://example.com/startrekbeyond.jpg', 'SF', 'PG_13', '2016-07-22', 122, true), + ('Pacific Rim', 'https://example.com/pacificrim.jpg', 'SF', 'PG_13', '2013-07-12', 131, false), + ('Cloverfield', 'https://example.com/cloverfield.jpg', 'SF', 'PG_13', '2008-01-18', 85, true), + ('District 9', 'https://example.com/district9.jpg', 'SF', 'R', '2009-08-14', 112, true), + ('Prometheus', 'https://example.com/prometheus.jpg', 'SF', 'R', '2012-06-08', 124, false), + ('War of the Worlds', 'https://example.com/waroftheworlds.jpg', 'SF', 'PG_13', '2005-06-29', 116, true), + ('The Day the Earth Stood Still', 'https://example.com/dayearthstoodstill.jpg', 'SF', 'PG_13', '2008-12-12', 104, false), + ('10 Cloverfield Lane', 'https://example.com/10cloverfieldlane.jpg', 'SF', 'PG_13', '2016-03-11', 104, true), + ('The Bourne Identity', 'https://example.com/bourneidentity.jpg', 'ACTION', 'PG_13', '2002-06-14', 119, false), + ('The Bourne Supremacy', 'https://example.com/bournesupremacy.jpg', 'ACTION', 'PG_13', '2004-07-23', 108, true), + ('The Bourne Ultimatum', 'https://example.com/bourneultimatum.jpg', 'ACTION', 'PG_13', '2007-08-03', 115, false), + ('The Bourne Legacy', 'https://example.com/bournelegacy.jpg', 'ACTION', 'PG_13', '2012-08-10', 135, true), + ('Jason Bourne', 'https://example.com/jasonbourne.jpg', 'ACTION', 'PG_13', '2016-07-29', 123, false), + ('The Hunger Games', 'https://example.com/hungergames.jpg', 'ACTION', 'PG_13', '2012-03-23', 142, true), + ('Catching Fire', 'https://example.com/catchingfire.jpg', 'ACTION', 'PG_13', '2013-11-22', 146, false), + ('Mockingjay Part 1', 'https://example.com/mockingjay1.jpg', 'ACTION', 'PG_13', '2014-11-21', 123, true), + ('Mockingjay Part 2', 'https://example.com/mockingjay2.jpg', 'ACTION', 'PG_13', '2015-11-20', 137, false), + ('Divergent', 'https://example.com/divergent.jpg', 'ACTION', 'PG_13', '2014-03-21', 139, true), + ('Insurgent', 'https://example.com/insurgent.jpg', 'ACTION', 'PG_13', '2015-03-20', 119, false), + ('Allegiant', 'https://example.com/allegiant.jpg', 'ACTION', 'PG_13', '2016-03-18', 121, true), + ('The Fault in Our Stars', 'https://example.com/faultinourstars.jpg', 'ROMANCE', 'PG_13', '2014-06-06', 126, false), + ('The Longest Ride', 'https://example.com/longestride.jpg', 'ROMANCE', 'PG_13', '2015-04-10', 123, true), + ('Safe Haven', 'https://example.com/safehaven.jpg', 'ROMANCE', 'PG_13', '2013-02-14', 115, false), + ('Dear John', 'https://example.com/dearjohn.jpg', 'ROMANCE', 'PG_13', '2010-02-05', 108, true), + ('The Last Song', 'https://example.com/lastsong.jpg', 'ROMANCE', 'PG', '2010-03-31', 107, false), + ('A Walk to Remember', 'https://example.com/walktoremember.jpg', 'ROMANCE', 'PG', '2002-01-25', 101, true), + ('The Vow', 'https://example.com/vow.jpg', 'ROMANCE', 'PG_13', '2012-02-10', 104, false), + ('Endless Love', 'https://example.com/endlesslove.jpg', 'ROMANCE', 'PG_13', '2014-02-14', 104, true), + ('The Best of Me', 'https://example.com/bestofme.jpg', 'ROMANCE', 'PG_13', '2014-10-17', 118, false), + ('Me Before You', 'https://example.com/mebeforeyou.jpg', 'ROMANCE', 'PG_13', '2016-06-03', 110, true), + ('If I Stay', 'https://example.com/ifistay.jpg', 'ROMANCE', 'PG_13', '2014-08-22', 107, false), + ('Before Sunrise', 'https://example.com/beforesunrise.jpg', 'ROMANCE', 'R', '1995-01-27', 101, true), + ('Before Sunset', 'https://example.com/beforesunset.jpg', 'ROMANCE', 'R', '2004-07-02', 80, false), + ('Before Midnight', 'https://example.com/beforemidnight.jpg', 'ROMANCE', 'R', '2013-06-14', 109, true), + ('Scream', 'https://example.com/scream.jpg', 'HORROR', 'R', '1996-12-20', 111, false), + ('Scream 2', 'https://example.com/scream2.jpg', 'HORROR', 'R', '1997-12-12', 120, true), + ('Scream 3', 'https://example.com/scream3.jpg', 'HORROR', 'R', '2000-02-04', 116, false), + ('Scream 4', 'https://example.com/scream4.jpg', 'HORROR', 'R', '2011-04-15', 111, true), + ('Scream 5', 'https://example.com/scream5.jpg', 'HORROR', 'R', '2022-01-14', 114, false), + ('The Ring', 'https://example.com/thering.jpg', 'HORROR', 'PG_13', '2002-10-18', 115, true), + ('The Ring Two', 'https://example.com/theringtwo.jpg', 'HORROR', 'PG_13', '2005-03-18', 110, false), + ('Paranormal Activity', 'https://example.com/paranormalactivity.jpg', 'HORROR', 'R', '2007-10-14', 86, true), + ('Paranormal Activity 2', 'https://example.com/paranormalactivity2.jpg', 'HORROR', 'R', '2010-10-22', 91, false), + ('Paranormal Activity 3', 'https://example.com/paranormalactivity3.jpg', 'HORROR', 'R', '2011-10-21', 84, true), + ('Paranormal Activity 4', 'https://example.com/paranormalactivity4.jpg', 'HORROR', 'R', '2012-10-19', 88, false), + ('Paranormal Activity: The Ghost Dimension', 'https://example.com/paranormalactivity5.jpg', 'HORROR', 'R', '2015-10-23', 88, true), + ('Event Horizon', 'https://example.com/eventhorizon.jpg', 'SF', 'R', '1997-08-15', 96, false), + ('Sunshine', 'https://example.com/sunshine.jpg', 'SF', 'R', '2007-07-27', 107, true), + ('Contact', 'https://example.com/contact.jpg', 'SF', 'PG', '1997-07-11', 150, false), + ('Interstellar', 'https://example.com/interstellar.jpg', 'SF', 'PG_13', '2014-11-07', 169, true), + ('The Martian', 'https://example.com/martian.jpg', 'SF', 'PG_13', '2015-10-02', 144, false), + ('Ad Astra', 'https://example.com/adastra.jpg', 'SF', 'PG_13', '2019-09-20', 123, true), + ('Gravity', 'https://example.com/gravity.jpg', 'SF', 'PG_13', '2013-10-04', 91, false), + ('Elysium', 'https://example.com/elysium.jpg', 'SF', 'R', '2013-08-09', 109, true), + ('Oblivion', 'https://example.com/oblivion.jpg', 'SF', 'PG_13', '2013-04-19', 124, false), + ('Edge of Tomorrow', 'https://example.com/edgeoftomorrow.jpg', 'SF', 'PG_13', '2014-06-06', 113, true), + ('Passengers', 'https://example.com/passengers.jpg', 'SF', 'PG_13', '2016-12-21', 116, true), + ('Arrival', 'https://example.com/arrival.jpg', 'SF', 'PG_13', '2016-11-11', 116, true), + ('Transformers', 'https://example.com/transformers.jpg', 'ACTION', 'PG_13', '2007-07-03', 144, true), + ('Transformers: Revenge of the Fallen', 'https://example.com/transformers2.jpg', 'ACTION', 'PG_13', '2009-06-24', 150, true), + ('Transformers: Dark of the Moon', 'https://example.com/transformers3.jpg', 'ACTION', 'PG_13', '2011-06-29', 154, false), + ('Transformers: Age of Extinction', 'https://example.com/transformers4.jpg', 'ACTION', 'PG_13', '2014-06-27', 165, true), + ('Transformers: The Last Knight', 'https://example.com/transformers5.jpg', 'ACTION', 'PG_13', '2017-06-21', 149, true), + ('Bumblebee', 'https://example.com/bumblebee.jpg', 'ACTION', 'PG_13', '2018-12-21', 114, true), + ('Pacific Rim: Uprising', 'https://example.com/pacificrimuprising.jpg', 'ACTION', 'PG_13', '2018-03-23', 111, true), + ('John Wick', 'https://example.com/johnwick.jpg', 'ACTION', 'R', '2014-10-24', 101, true), + ('John Wick: Chapter 2', 'https://example.com/johnwick2.jpg', 'ACTION', 'R', '2017-02-10', 122, true), + ('John Wick: Chapter 3 - Parabellum', 'https://example.com/johnwick3.jpg', 'ACTION', 'R', '2019-05-17', 130, true), + ('The Equalizer', 'https://example.com/equalizer.jpg', 'ACTION', 'R', '2014-09-26', 132, true), + ('The Equalizer 2', 'https://example.com/equalizer2.jpg', 'ACTION', 'R', '2018-07-20', 121, false), + ('The Meg', 'https://example.com/meg.jpg', 'ACTION', 'PG_13', '2018-08-10', 113, true), + ('The Accountant', 'https://example.com/accountant.jpg', 'ACTION', 'R', '2016-10-14', 128, true), + ('The Old Guard', 'https://example.com/oldguard.jpg', 'ACTION', 'R', '2020-07-10', 125, true), + ('Love, Simon', 'https://example.com/lovesimon.jpg', 'ROMANCE', 'PG_13', '2018-03-16', 110, true), + ('Crazy Rich Asians', 'https://example.com/crazyrichasians.jpg', 'ROMANCE', 'PG_13', '2018-08-15', 120, true), + ('Pride and Prejudice', 'https://example.com/prideandprejudice.jpg', 'ROMANCE', 'PG', '2005-11-23', 129, true), + ('Sense and Sensibility', 'https://example.com/senseandsensibility.jpg', 'ROMANCE', 'PG', '1995-12-13', 136, true), + ('Emma', 'https://example.com/emma.jpg', 'ROMANCE', 'PG', '2020-02-21', 124, true), + ('Clueless', 'https://example.com/clueless.jpg', 'ROMANCE', 'PG_13', '1995-07-19', 97, false), + ('Notting Hill', 'https://example.com/nottinghill.jpg', 'ROMANCE', 'PG_13', '1999-05-28', 124, true), + ('Four Weddings and a Funeral', 'https://example.com/fourweddings.jpg', 'ROMANCE', 'R', '1994-03-09', 117, true), + ('About a Boy', 'https://example.com/aboutaboy.jpg', 'ROMANCE', 'PG_13', '2002-04-26', 101, true), + ('Yesterday', 'https://example.com/yesterday.jpg', 'ROMANCE', 'PG_13', '2019-06-28', 116, true), + ('Warm Bodies', 'https://example.com/warmbodies.jpg', 'ROMANCE', 'PG_13', '2013-02-01', 98, false), + ('Crimson Peak', 'https://example.com/crimsonpeak.jpg', 'HORROR', 'R', '2015-10-16', 119, true), + ('The Cabin in the Woods', 'https://example.com/cabininthewoods.jpg', 'HORROR', 'R', '2012-04-13', 95, true), + ('Insidious', 'https://example.com/insidious.jpg', 'HORROR', 'PG_13', '2011-04-01', 103, true), + ('Insidious: Chapter 2', 'https://example.com/insidious2.jpg', 'HORROR', 'PG_13', '2013-09-13', 106, true), + ('Insidious: Chapter 3', 'https://example.com/insidious3.jpg', 'HORROR', 'PG_13', '2015-06-05', 97, true), + ('Sinister', 'https://example.com/sinister.jpg', 'HORROR', 'R', '2012-10-12', 110, true), + ('Sinister 2', 'https://example.com/sinister2.jpg', 'HORROR', 'R', '2015-08-21', 97, false), + ('The Possession', 'https://example.com/possession.jpg', 'HORROR', 'PG_13', '2012-08-31', 92, true), + ('Lights Out', 'https://example.com/lightsout.jpg', 'HORROR', 'PG_13', '2016-07-22', 81, true), + ('Train to Busan', 'https://example.com/traintobusan.jpg', 'HORROR', 'R', '2016-07-20', 118, true), + ('World War Z', 'https://example.com/worldwarz.jpg', 'HORROR', 'PG_13', '2013-06-21', 116, true), + ('I Am Legend', 'https://example.com/iamlegend.jpg', 'HORROR', 'PG_13', '2007-12-14', 101, true), + ('28 Days Later', 'https://example.com/28dayslater.jpg', 'HORROR', 'R', '2002-11-01', 113, false), + ('28 Weeks Later', 'https://example.com/28weekslater.jpg', 'HORROR', 'R', '2007-05-11', 100, true), + ('Gattaca', 'https://example.com/gattaca.jpg', 'SF', 'PG_13', '1997-10-24', 106, true), + ('Children of Men', 'https://example.com/childrenofmen.jpg', 'SF', 'R', '2006-12-25', 109, true), + ('A.I. Artificial Intelligence', 'https://example.com/ai.jpg', 'SF', 'PG_13', '2001-06-29', 146, true), + ('Blade Runner', 'https://example.com/bladerunner.jpg', 'SF', 'R', '1982-06-25', 117, true), + ('Blade Runner 2049', 'https://example.com/bladerunner2049.jpg', 'SF', 'R', '2017-10-06', 164, true), + ('The Fifth Element', 'https://example.com/fifthelement.jpg', 'SF', 'PG_13', '1997-05-09', 126, false), + ('Moon', 'https://example.com/moon.jpg', 'SF', 'R', '2009-07-17', 97, true), + ('Snowpiercer', 'https://example.com/snowpiercer.jpg', 'SF', 'R', '2014-06-27', 126, true), + ('Her', 'https://example.com/her.jpg', 'SF', 'R', '2013-12-18', 126, true), + ('Under the Skin', 'https://example.com/undertheskin.jpg', 'SF', 'R', '2014-04-04', 108, true), + ('Taken', 'https://example.com/taken.jpg', 'ACTION', 'PG_13', '2008-01-30', 93, true), + ('Taken 2', 'https://example.com/taken2.jpg', 'ACTION', 'PG_13', '2012-09-21', 92, true), + ('Taken 3', 'https://example.com/taken3.jpg', 'ACTION', 'PG_13', '2014-12-16', 109, true), + ('Non-Stop', 'https://example.com/nonstop.jpg', 'ACTION', 'PG_13', '2014-02-28', 106, true), + ('Unknown', 'https://example.com/unknown.jpg', 'ACTION', 'PG_13', '2011-02-18', 113, false), + ('The Commuter', 'https://example.com/commuter.jpg', 'ACTION', 'PG_13', '2018-01-12', 105, true), + ('Run All Night', 'https://example.com/runallnight.jpg', 'ACTION', 'R', '2015-03-13', 114, true), + ('Colombiana', 'https://example.com/colombiana.jpg', 'ACTION', 'PG_13', '2011-08-26', 108, true), + ('Salt', 'https://example.com/salt.jpg', 'ACTION', 'PG_13', '2010-07-23', 100, true), + ('Safe House', 'https://example.com/safehouse.jpg', 'ACTION', 'R', '2012-02-10', 115, true), + ('The Italian Job', 'https://example.com/italianjob.jpg', 'ACTION', 'PG_13', '2003-05-30', 111, true), + ('Man on Fire', 'https://example.com/manonfire.jpg', 'ACTION', 'R', '2004-04-23', 146, false), + ('Shooter', 'https://example.com/shooter.jpg', 'ACTION', 'R', '2007-03-23', 124, true), + ('13 Hours', 'https://example.com/13hours.jpg', 'ACTION', 'R', '2016-01-15', 144, true), + ('Black Hawk Down', 'https://example.com/blackhawkdown.jpg', 'ACTION', 'R', '2001-12-28', 144, true), + ('American Assassin', 'https://example.com/americanassassin.jpg', 'ACTION', 'R', '2017-09-15', 111, true), + ('The November Man', 'https://example.com/novemberman.jpg', 'ACTION', 'R', '2014-08-27', 108, true), + ('Red Sparrow', 'https://example.com/redsparrow.jpg', 'ACTION', 'R', '2018-03-02', 140, true), + ('Anna', 'https://example.com/anna.jpg', 'ACTION', 'R', '2019-06-21', 119, true), + ('Love Rosie', 'https://example.com/loverosie.jpg', 'ROMANCE', 'PG_13', '2014-10-22', 102, false), + ('500 Days of Summer', 'https://example.com/500days.jpg', 'ROMANCE', 'PG_13', '2009-07-17', 95, true), + ('One Day', 'https://example.com/oneday.jpg', 'ROMANCE', 'PG_13', '2011-08-19', 107, true), + ('Brooklyn', 'https://example.com/brooklyn.jpg', 'ROMANCE', 'PG_13', '2015-11-04', 111, true), + ('Far from the Madding Crowd', 'https://example.com/maddingcrowd.jpg', 'ROMANCE', 'PG_13', '2015-05-01', 119, true), + ('Before We Go', 'https://example.com/beforewego.jpg', 'ROMANCE', 'PG_13', '2015-09-04', 89, true), + ('The Holiday', 'https://example.com/holiday.jpg', 'ROMANCE', 'PG_13', '2006-12-08', 136, true), + ('Midnight in Paris', 'https://example.com/midnightinparis.jpg', 'ROMANCE', 'PG_13', '2011-06-10', 94, true), + ('Vicky Cristina Barcelona', 'https://example.com/vickycristina.jpg', 'ROMANCE', 'PG_13', '2008-08-15', 96, true), + ('Ghost', 'https://example.com/ghost.jpg', 'ROMANCE', 'PG_13', '1990-07-13', 127, false), + ('Sleepy Hollow', 'https://example.com/sleepyhollow.jpg', 'HORROR', 'R', '1999-11-19', 105, true), + ('The Haunting', 'https://example.com/haunting.jpg', 'HORROR', 'PG_13', '1999-07-23', 113, true), + ('The Others', 'https://example.com/theothers.jpg', 'HORROR', 'PG_13', '2001-08-02', 104, true), + ('The Witch', 'https://example.com/thewitch.jpg', 'HORROR', 'R', '2015-01-27', 92, true), + ('The Lighthouse', 'https://example.com/lighthouse.jpg', 'HORROR', 'R', '2019-10-18', 109, true), + ('Midsommar', 'https://example.com/midsommar.jpg', 'HORROR', 'R', '2019-07-03', 148, false), + ('Us', 'https://example.com/us.jpg', 'HORROR', 'R', '2019-03-22', 116, true), + ('The Babadook', 'https://example.com/babadook.jpg', 'HORROR', 'R', '2014-11-28', 94, true), + ('The Exorcism of Emily Rose', 'https://example.com/emilyrose.jpg', 'HORROR', 'PG_13', '2005-09-09', 119, true), + ('Drag Me to Hell', 'https://example.com/dragmetohell.jpg', 'HORROR', 'PG_13', '2009-05-29', 99, true), + ('Sunshine', 'https://example.com/sunshine.jpg', 'SF', 'R', '2007-07-27', 107, true), + ('The Andromeda Strain', 'https://example.com/andromedastrain.jpg', 'SF', 'PG', '1971-03-12', 131, false), + ('Contact', 'https://example.com/contact.jpg', 'SF', 'PG', '1997-07-11', 150, true), + ('Soylent Green', 'https://example.com/soylentgreen.jpg', 'SF', 'PG', '1973-04-19', 97, true), + ('THX 1138', 'https://example.com/thx1138.jpg', 'SF', 'R', '1971-03-11', 88, true), + ('Silent Running', 'https://example.com/silentrunning.jpg', 'SF', 'PG', '1972-03-10', 89, true), + ('Stalker', 'https://example.com/stalker.jpg', 'SF', 'PG', '1979-05-25', 163, true), + ('Alphaville', 'https://example.com/alphaville.jpg', 'SF', 'PG', '1965-05-05', 99, true), + ('Coherence', 'https://example.com/coherence.jpg', 'SF', 'PG_13', '2013-09-19', 89, true), + ('Primer', 'https://example.com/primer.jpg', 'SF', 'PG_13', '2004-10-08', 77, false), + ('Cube', 'https://example.com/cube.jpg', 'SF', 'R', '1997-09-09', 90, true); + +INSERT INTO screen_room (name) +VALUES + ('Screen Room A'), + ('Screen Room B'), + ('IMAX Room'), + ('VIP Room'), + ('Screen Room C'), + ('Screen Room D'), + ('4DX Room'), + ('Premier Room'), + ('Family Room'), + ('Kids Room'); + +INSERT INTO screen_schedule (start_time, end_time, movie_id, screen_room_id) +VALUES + ('2025-01-10 10:00:00', '2025-01-10 12:30:00', 1, 1), + ('2025-01-11 18:00:00', '2025-01-11 20:30:00', 1, 4), + ('2025-01-12 16:00:00', '2025-01-12 17:15:00', 1, 1), + ('2025-01-10 13:00:00', '2025-01-10 15:30:00', 2, 2), + ('2025-01-11 21:00:00', '2025-01-11 23:45:00', 2, 5), + ('2025-01-10 09:00:00', '2025-01-10 11:15:00', 3, 1), + ('2025-01-10 16:00:00', '2025-01-10 18:15:00', 3, 3), + ('2025-01-12 12:00:00', '2025-01-12 14:30:00', 3, 2), + ('2025-01-12 15:00:00', '2025-01-12 17:15:00', 4, 3), + ('2025-01-10 19:00:00', '2025-01-10 21:45:00', 4, 4), + ('2025-01-11 09:30:00', '2025-01-11 11:45:00', 4, 1), + ('2025-01-10 12:00:00', '2025-01-10 14:00:00', 5, 2), + ('2025-01-10 22:00:00', '2025-01-11 00:30:00', 5, 5), + ('2025-01-11 10:00:00', '2025-01-11 12:15:00', 6, 1), + ('2025-01-12 17:45:00', '2025-01-12 19:45:00', 6, 4), + ('2025-01-10 20:00:00', '2025-01-10 22:15:00', 6, 5), + ('2025-01-11 13:00:00', '2025-01-11 15:00:00', 7, 2), + ('2025-01-10 14:30:00', '2025-01-10 16:45:00', 7, 3), + ('2025-01-12 20:00:00', '2025-01-12 22:30:00', 7, 5), + ('2025-01-11 12:30:00', '2025-01-11 14:30:00', 8, 2), + ('2025-01-11 16:00:00', '2025-01-11 18:00:00', 8, 3), + ('2025-01-11 19:00:00', '2025-01-11 21:30:00', 9, 4), + ('2025-01-10 17:30:00', '2025-01-10 19:45:00', 9, 4), + ('2025-01-11 15:00:00', '2025-01-11 17:30:00', 10, 3), + ('2025-01-11 22:00:00', '2025-01-12 00:15:00', 10, 5), + ('2025-01-10 10:00:00', '2025-01-10 12:00:00', 11, 1), + ('2025-01-10 13:30:00', '2025-01-10 15:15:00', 11, 2), + ('2025-01-11 16:00:00', '2025-01-11 18:30:00', 12, 3), + ('2025-01-12 19:00:00', '2025-01-12 21:15:00', 12, 4), + ('2025-01-10 21:30:00', '2025-01-10 23:45:00', 13, 5), + ('2025-01-11 09:00:00', '2025-01-11 11:00:00', 13, 1), + ('2025-01-12 13:00:00', '2025-01-12 15:30:00', 14, 2), + ('2025-01-12 17:00:00', '2025-01-12 19:15:00', 14, 3), + ('2025-01-13 10:00:00', '2025-01-13 12:15:00', 15, 4), + ('2025-01-13 15:00:00', '2025-01-13 17:30:00', 15, 5), + ('2025-01-14 10:30:00', '2025-01-14 12:45:00', 16, 1), + ('2025-01-14 14:00:00', '2025-01-14 16:30:00', 16, 2), + ('2025-01-15 11:00:00', '2025-01-15 13:15:00', 17, 3), + ('2025-01-15 17:00:00', '2025-01-15 19:15:00', 17, 4), + ('2025-01-16 10:00:00', '2025-01-16 12:30:00', 18, 5), + ('2025-01-16 13:00:00', '2025-01-16 15:15:00', 18, 1), + ('2025-01-17 09:30:00', '2025-01-17 11:30:00', 19, 2), + ('2025-01-17 14:00:00', '2025-01-17 16:00:00', 19, 3), + ('2025-01-18 10:00:00', '2025-01-18 12:15:00', 20, 4), + ('2025-01-18 16:00:00', '2025-01-18 18:30:00', 20, 5), + ('2025-01-19 10:30:00', '2025-01-19 12:45:00', 21, 1), + ('2025-01-19 13:30:00', '2025-01-19 15:45:00', 21, 2), + ('2025-01-20 11:00:00', '2025-01-20 13:15:00', 22, 3), + ('2025-01-20 14:00:00', '2025-01-20 16:30:00', 22, 4), + ('2025-01-21 10:00:00', '2025-01-21 12:15:00', 23, 5), + ('2025-01-21 15:00:00', '2025-01-21 17:15:00', 23, 1), + ('2025-01-22 10:00:00', '2025-01-22 12:15:00', 24, 2), + ('2025-01-22 16:00:00', '2025-01-22 18:15:00', 24, 3), + ('2025-01-23 09:00:00', '2025-01-23 11:15:00', 25, 4), + ('2025-01-23 13:30:00', '2025-01-23 15:45:00', 25, 5), + ('2025-01-24 11:00:00', '2025-01-24 13:00:00', 26, 1), + ('2025-01-24 14:30:00', '2025-01-24 16:30:00', 26, 2), + ('2025-01-25 10:00:00', '2025-01-25 12:15:00', 27, 3), + ('2025-01-25 16:00:00', '2025-01-25 18:30:00', 27, 4), + ('2025-01-26 10:30:00', '2025-01-26 12:45:00', 28, 5), + ('2025-01-26 15:00:00', '2025-01-26 17:15:00', 28, 1), + ('2025-01-27 09:30:00', '2025-01-27 11:30:00', 29, 2), + ('2025-01-27 14:00:00', '2025-01-27 16:00:00', 29, 3), + ('2025-01-28 10:00:00', '2025-01-28 12:15:00', 30, 4), + ('2025-01-28 16:00:00', '2025-01-28 18:30:00', 30, 5), + ('2025-01-29 10:30:00', '2025-01-29 12:45:00', 31, 1), + ('2025-01-29 13:30:00', '2025-01-29 15:45:00', 31, 2), + ('2025-01-30 11:00:00', '2025-01-30 13:15:00', 32, 3), + ('2025-01-30 14:00:00', '2025-01-30 16:30:00', 32, 4), + ('2025-01-31 10:00:00', '2025-01-31 12:15:00', 33, 5), + ('2025-01-31 15:00:00', '2025-01-31 17:15:00', 33, 1), + ('2025-02-01 10:00:00', '2025-02-01 12:15:00', 34, 2), + ('2025-02-01 16:00:00', '2025-02-01 18:15:00', 34, 3), + ('2025-02-02 09:00:00', '2025-02-02 11:15:00', 35, 4), + ('2025-02-02 13:30:00', '2025-02-02 15:45:00', 35, 5), + ('2025-02-03 11:00:00', '2025-02-03 13:00:00', 36, 1), + ('2025-02-03 14:30:00', '2025-02-03 16:30:00', 36, 2), + ('2025-02-05 10:00:00', '2025-02-05 12:15:00', 37, 1), + ('2025-02-05 13:30:00', '2025-02-05 15:45:00', 37, 2), + ('2025-02-06 16:00:00', '2025-02-06 18:30:00', 38, 3), + ('2025-02-06 19:00:00', '2025-02-06 21:15:00', 38, 4), + ('2025-02-07 09:00:00', '2025-02-07 11:00:00', 39, 5), + ('2025-02-07 12:30:00', '2025-02-07 14:45:00', 39, 1), + ('2025-02-08 10:00:00', '2025-02-08 12:00:00', 40, 2), + ('2025-02-08 13:30:00', '2025-02-08 15:30:00', 40, 3), + ('2025-02-09 11:00:00', '2025-02-09 13:15:00', 41, 4), + ('2025-02-09 14:00:00', '2025-02-09 16:00:00', 41, 5), + ('2025-02-10 09:00:00', '2025-02-10 11:15:00', 42, 1), + ('2025-02-10 12:30:00', '2025-02-10 14:45:00', 42, 2), + ('2025-02-11 15:00:00', '2025-02-11 17:30:00', 43, 3), + ('2025-02-11 18:00:00', '2025-02-11 20:30:00', 43, 4), + ('2025-02-12 10:30:00', '2025-02-12 12:30:00', 44, 5), + ('2025-02-12 13:00:00', '2025-02-12 15:00:00', 44, 1), + ('2025-02-13 09:30:00', '2025-02-13 11:30:00', 45, 2), + ('2025-02-13 14:00:00', '2025-02-13 16:15:00', 45, 3), + ('2025-02-14 10:00:00', '2025-02-14 12:15:00', 46, 4), + ('2025-02-14 16:00:00', '2025-02-14 18:15:00', 46, 5), + ('2025-02-15 10:30:00', '2025-02-15 12:45:00', 47, 1), + ('2025-02-15 13:30:00', '2025-02-15 15:30:00', 47, 2), + ('2025-02-16 11:00:00', '2025-02-16 13:15:00', 48, 3), + ('2025-02-16 14:00:00', '2025-02-16 16:15:00', 48, 4), + ('2025-02-17 10:00:00', '2025-02-17 12:15:00', 49, 5), + ('2025-02-17 15:00:00', '2025-02-17 17:15:00', 49, 1), + ('2025-02-18 10:00:00', '2025-02-18 12:15:00', 50, 2), + ('2025-02-18 16:00:00', '2025-02-18 18:15:00', 50, 3), + ('2025-02-19 09:00:00', '2025-02-19 11:15:00', 51, 4), + ('2025-02-19 13:30:00', '2025-02-19 15:45:00', 51, 5), + ('2025-02-20 11:00:00', '2025-02-20 13:00:00', 52, 1), + ('2025-02-20 14:30:00', '2025-02-20 16:30:00', 52, 2), + ('2025-02-21 10:00:00', '2025-02-21 12:15:00', 53, 3), + ('2025-02-21 16:00:00', '2025-02-21 18:30:00', 53, 4), + ('2025-02-22 10:30:00', '2025-02-22 12:45:00', 54, 5), + ('2025-02-22 15:00:00', '2025-02-22 17:15:00', 54, 1), + ('2025-02-23 09:30:00', '2025-02-23 11:30:00', 55, 2), + ('2025-02-23 14:00:00', '2025-02-23 16:00:00', 55, 3), + ('2025-01-10 10:00:00', '2025-01-10 12:00:00', 56, 1), + ('2025-01-10 12:30:00', '2025-01-10 14:30:00', 56, 2), + ('2025-01-10 15:00:00', '2025-01-10 17:00:00', 56, 3), + ('2025-01-11 10:00:00', '2025-01-11 12:00:00', 56, 4), + ('2025-01-11 12:30:00', '2025-01-11 14:30:00', 56, 5), + ('2025-01-10 11:00:00', '2025-01-10 13:15:00', 57, 6), + ('2025-01-10 13:45:00', '2025-01-10 15:45:00', 57, 7), + ('2025-01-11 16:00:00', '2025-01-11 18:30:00', 57, 8), + ('2025-01-11 19:00:00', '2025-01-11 21:30:00', 57, 9), + ('2025-01-12 10:00:00', '2025-01-12 12:30:00', 57, 10), + ('2025-01-10 10:30:00', '2025-01-10 12:30:00', 58, 1), + ('2025-01-10 13:00:00', '2025-01-10 15:00:00', 58, 2), + ('2025-01-11 11:00:00', '2025-01-11 13:00:00', 58, 3), + ('2025-01-12 10:00:00', '2025-01-12 12:15:00', 59, 6), + ('2025-01-12 13:00:00', '2025-01-12 15:15:00', 59, 8), + ('2025-01-12 16:00:00', '2025-01-12 18:30:00', 59, 8), + ('2025-01-10 09:00:00', '2025-01-10 11:00:00', 60, 1), + ('2025-01-10 10:00:00', '2025-01-10 12:15:00', 61, 7), + ('2025-01-11 12:30:00', '2025-01-11 14:45:00', 61, 2), + ('2025-01-12 15:00:00', '2025-01-12 17:30:00', 62, 5), + ('2025-01-13 18:00:00', '2025-01-13 20:15:00', 62, 8), + ('2025-01-14 10:30:00', '2025-01-14 12:45:00', 63, 4), + ('2025-01-14 14:00:00', '2025-01-14 16:30:00', 63, 10), + ('2025-01-15 09:30:00', '2025-01-15 11:45:00', 64, 1), + ('2025-01-16 13:00:00', '2025-01-16 15:15:00', 64, 6), + ('2025-01-16 16:00:00', '2025-01-16 18:30:00', 65, 3), + ('2025-01-17 11:30:00', '2025-01-17 13:45:00', 65, 9), + ('2025-01-18 10:00:00', '2025-01-18 12:15:00', 66, 2), + ('2025-01-18 15:30:00', '2025-01-18 17:45:00', 66, 7), + ('2025-01-19 18:00:00', '2025-01-19 20:30:00', 67, 8), + ('2025-01-20 12:00:00', '2025-01-20 14:15:00', 67, 5), + ('2025-01-21 14:30:00', '2025-01-21 16:45:00', 68, 10), + ('2025-01-21 17:00:00', '2025-01-21 19:15:00', 68, 3), + ('2025-01-22 09:00:00', '2025-01-22 11:00:00', 69, 4), + ('2025-01-22 12:30:00', '2025-01-22 14:30:00', 69, 6), + ('2025-01-23 10:00:00', '2025-01-23 12:15:00', 70, 9), + ('2025-01-23 13:00:00', '2025-01-23 15:15:00', 70, 2), + ('2025-01-24 10:30:00', '2025-01-24 12:30:00', 71, 5), + ('2025-01-24 15:00:00', '2025-01-24 17:15:00', 71, 7), + ('2025-01-25 13:30:00', '2025-01-25 15:45:00', 72, 8), + ('2025-01-25 16:00:00', '2025-01-25 18:15:00', 72, 4), + ('2025-01-26 09:00:00', '2025-01-26 11:15:00', 73, 1), + ('2025-01-26 12:00:00', '2025-01-26 14:00:00', 73, 6), + ('2025-01-27 11:30:00', '2025-01-27 13:45:00', 74, 10), + ('2025-01-28 10:00:00', '2025-01-28 12:15:00', 75, 2), + ('2025-01-28 15:00:00', '2025-01-28 17:30:00', 75, 3), + ('2025-01-29 13:30:00', '2025-01-29 15:45:00', 76, 8), + ('2025-01-30 09:30:00', '2025-01-30 11:45:00', 76, 7), + ('2025-01-30 14:00:00', '2025-01-30 16:15:00', 77, 5), + ('2025-01-31 12:30:00', '2025-01-31 14:30:00', 77, 9), + ('2025-02-01 10:30:00', '2025-02-01 12:30:00', 78, 4), + ('2025-02-01 13:00:00', '2025-02-01 15:15:00', 78, 6), + ('2025-02-02 11:00:00', '2025-02-02 13:00:00', 79, 10), + ('2025-02-02 14:00:00', '2025-02-02 16:15:00', 79, 3), + ('2025-02-03 12:00:00', '2025-02-03 14:00:00', 80, 1), + ('2025-02-03 15:30:00', '2025-02-03 17:30:00', 80, 8), + ('2025-01-10 10:00:00', '2025-01-10 12:15:00', 81, 4), + ('2025-01-11 13:00:00', '2025-01-11 15:15:00', 81, 7), + ('2025-01-12 16:30:00', '2025-01-12 18:45:00', 81, 9), + ('2025-01-13 10:30:00', '2025-01-13 12:30:00', 82, 3), + ('2025-01-14 14:00:00', '2025-01-14 16:15:00', 82, 5), + ('2025-01-15 18:00:00', '2025-01-15 20:15:00', 82, 8), + ('2025-01-16 09:00:00', '2025-01-16 11:00:00', 83, 2), + ('2025-01-17 11:30:00', '2025-01-17 13:45:00', 83, 6), + ('2025-01-18 14:00:00', '2025-01-18 16:15:00', 83, 10), + ('2025-01-19 10:00:00', '2025-01-19 12:15:00', 84, 1), + ('2025-01-20 12:30:00', '2025-01-20 14:45:00', 84, 4), + ('2025-01-21 15:00:00', '2025-01-21 17:15:00', 84, 9), + ('2025-01-22 09:30:00', '2025-01-22 11:45:00', 85, 5), + ('2025-01-23 13:00:00', '2025-01-23 15:15:00', 85, 7), + ('2025-01-24 16:00:00', '2025-01-24 18:15:00', 85, 3), + ('2025-01-25 10:00:00', '2025-01-25 12:15:00', 86, 6), + ('2025-01-26 12:30:00', '2025-01-26 14:45:00', 86, 2), + ('2025-01-27 15:00:00', '2025-01-27 17:15:00', 86, 10), + ('2025-01-28 09:00:00', '2025-01-28 11:00:00', 87, 8), + ('2025-01-29 13:30:00', '2025-01-29 15:45:00', 87, 4), + ('2025-01-30 16:00:00', '2025-01-30 18:15:00', 87, 1), + ('2025-01-31 10:30:00', '2025-01-31 12:45:00', 88, 2), + ('2025-02-01 13:00:00', '2025-02-01 15:15:00', 88, 9), + ('2025-02-02 16:00:00', '2025-02-02 18:15:00', 88, 7), + ('2025-02-03 11:00:00', '2025-02-03 13:15:00', 89, 5), + ('2025-02-04 14:00:00', '2025-02-04 16:15:00', 89, 3), + ('2025-02-05 17:00:00', '2025-02-05 19:15:00', 89, 10), + ('2025-02-06 09:30:00', '2025-02-06 11:45:00', 90, 6), + ('2025-02-07 13:30:00', '2025-02-07 15:30:00', 90, 8), + ('2025-02-08 16:00:00', '2025-02-08 18:30:00', 90, 1), + ('2025-01-10 10:00:00', '2025-01-10 12:15:00', 91, 7), + ('2025-01-10 14:00:00', '2025-01-10 16:00:00', 91, 3), + ('2025-01-12 16:30:00', '2025-01-12 18:45:00', 92, 5), + ('2025-01-13 10:30:00', '2025-01-13 12:15:00', 93, 1), + ('2025-01-14 12:00:00', '2025-01-14 14:15:00', 93, 4), + ('2025-01-14 16:00:00', '2025-01-14 18:30:00', 94, 6), + ('2025-01-15 09:00:00', '2025-01-15 11:00:00', 95, 2), + ('2025-01-16 11:30:00', '2025-01-16 13:30:00', 96, 8), + ('2025-01-17 15:00:00', '2025-01-17 17:30:00', 96, 5), + ('2025-01-18 10:00:00', '2025-01-18 12:00:00', 97, 9), + ('2025-01-19 14:30:00', '2025-01-19 16:45:00', 98, 7), + ('2025-01-20 10:00:00', '2025-01-20 12:30:00', 99, 3), + ('2025-01-20 15:00:00', '2025-01-20 17:15:00', 100, 10), + ('2025-01-21 13:00:00', '2025-01-21 15:15:00', 101, 2), + ('2025-01-22 10:30:00', '2025-01-22 12:30:00', 102, 4), + ('2025-01-23 16:00:00', '2025-01-23 18:15:00', 102, 6), + ('2025-01-24 09:30:00', '2025-01-24 11:45:00', 103, 8), + ('2025-01-25 14:00:00', '2025-01-25 16:00:00', 104, 1), + ('2025-01-26 10:00:00', '2025-01-26 12:15:00', 105, 5), + ('2025-01-27 12:30:00', '2025-01-27 14:45:00', 106, 7), + ('2025-01-28 15:00:00', '2025-01-28 17:00:00', 107, 3), + ('2025-01-29 09:00:00', '2025-01-29 11:15:00', 108, 9), + ('2025-01-30 11:30:00', '2025-01-30 13:30:00', 109, 2), + ('2025-01-31 14:00:00', '2025-01-31 16:15:00', 110, 4), + ('2025-02-01 10:00:00', '2025-02-01 12:15:00', 111, 6), + ('2025-02-02 13:30:00', '2025-02-02 15:30:00', 112, 8), + ('2025-02-03 16:00:00', '2025-02-03 18:15:00', 113, 10), + ('2025-02-04 09:30:00', '2025-02-04 11:45:00', 114, 7), + ('2025-02-05 10:00:00', '2025-02-05 12:15:00', 115, 5), + ('2025-02-06 14:00:00', '2025-02-06 16:00:00', 116, 3), + ('2025-02-07 10:00:00', '2025-02-07 12:30:00', 117, 9), + ('2025-02-08 13:00:00', '2025-02-08 15:15:00', 118, 2), + ('2025-02-09 10:30:00', '2025-02-09 12:45:00', 119, 4), + ('2025-02-10 14:00:00', '2025-02-10 16:15:00', 120, 6), + ('2025-02-11 09:00:00', '2025-02-11 11:00:00', 121, 8), + ('2025-02-12 13:30:00', '2025-02-12 15:30:00', 122, 1), + ('2025-02-13 10:00:00', '2025-02-13 12:15:00', 123, 5), + ('2025-02-14 16:00:00', '2025-02-14 18:15:00', 124, 7), + ('2025-02-15 09:30:00', '2025-02-15 11:45:00', 125, 3), + ('2025-02-16 10:00:00', '2025-02-16 12:30:00', 126, 9), + ('2025-02-17 13:00:00', '2025-02-17 15:00:00', 127, 2), + ('2025-02-18 10:30:00', '2025-02-18 12:45:00', 128, 4), + ('2025-02-19 14:00:00', '2025-02-19 16:00:00', 129, 6), + ('2025-02-20 09:00:00', '2025-02-20 11:15:00', 130, 8), + ('2025-02-21 13:30:00', '2025-02-21 15:30:00', 131, 10), + ('2025-02-22 10:00:00', '2025-02-22 12:15:00', 132, 7), + ('2025-02-23 16:00:00', '2025-02-23 18:15:00', 133, 5), + ('2025-02-24 09:30:00', '2025-02-24 11:45:00', 134, 3), + ('2025-02-25 10:00:00', '2025-02-25 12:30:00', 135, 9), + ('2025-02-26 13:00:00', '2025-02-26 15:15:00', 136, 2), + ('2025-02-27 10:30:00', '2025-02-27 12:45:00', 137, 4), + ('2025-02-28 14:00:00', '2025-02-28 16:15:00', 138, 6), + ('2025-03-01 09:00:00', '2025-03-01 11:15:00', 139, 8), + ('2025-03-02 13:30:00', '2025-03-02 15:30:00', 140, 10), + ('2025-03-03 10:00:00', '2025-03-03 12:15:00', 141, 7), + ('2025-03-04 16:00:00', '2025-03-04 18:15:00', 142, 5), + ('2025-03-05 09:30:00', '2025-03-05 11:45:00', 143, 3), + ('2025-03-06 10:00:00', '2025-03-06 12:30:00', 144, 9), + ('2025-03-07 13:00:00', '2025-03-07 15:00:00', 145, 2), + ('2025-03-08 10:30:00', '2025-03-08 12:45:00', 146, 4), + ('2025-03-09 14:00:00', '2025-03-09 16:00:00', 147, 6), + ('2025-03-10 09:00:00', '2025-03-10 11:15:00', 148, 8), + ('2025-03-11 13:30:00', '2025-03-11 15:30:00', 149, 10), + ('2025-03-12 10:00:00', '2025-03-12 12:15:00', 150, 7), + ('2025-01-15 10:00:00', '2025-01-15 12:15:00', 151, 5), + ('2025-01-16 13:00:00', '2025-01-16 15:15:00', 151, 2), + ('2025-01-17 16:00:00', '2025-01-17 18:30:00', 152, 7), + ('2025-01-18 09:30:00', '2025-01-18 11:45:00', 153, 3), + ('2025-01-19 10:00:00', '2025-01-19 12:00:00', 153, 6), + ('2025-01-20 14:00:00', '2025-01-20 16:00:00', 154, 8), + ('2025-01-21 15:30:00', '2025-01-21 17:45:00', 155, 1), + ('2025-01-22 10:00:00', '2025-01-22 12:15:00', 156, 9), + ('2025-01-23 14:00:00', '2025-01-23 16:30:00', 157, 4), + ('2025-01-24 13:00:00', '2025-01-24 15:15:00', 158, 10), + ('2025-01-25 10:30:00', '2025-01-25 12:30:00', 159, 7), + ('2025-01-26 09:00:00', '2025-01-26 11:15:00', 160, 5), + ('2025-01-27 16:00:00', '2025-01-27 18:15:00', 161, 2), + ('2025-01-28 12:30:00', '2025-01-28 14:45:00', 162, 4), + ('2025-01-29 14:00:00', '2025-01-29 16:15:00', 163, 9), + ('2025-01-30 10:00:00', '2025-01-30 12:15:00', 164, 6), + ('2025-01-31 15:30:00', '2025-01-31 17:30:00', 165, 3), + ('2025-02-01 10:00:00', '2025-02-01 12:30:00', 166, 8), + ('2025-02-02 13:00:00', '2025-02-02 15:00:00', 167, 1), + ('2025-02-03 09:30:00', '2025-02-03 11:30:00', 168, 7), + ('2025-02-04 14:00:00', '2025-02-04 16:30:00', 169, 5), + ('2025-02-05 12:30:00', '2025-02-05 14:30:00', 170, 2), + ('2025-02-06 10:00:00', '2025-02-06 12:15:00', 171, 4), + ('2025-02-07 13:30:00', '2025-02-07 15:30:00', 172, 9), + ('2025-02-08 16:00:00', '2025-02-08 18:15:00', 173, 6), + ('2025-02-09 10:00:00', '2025-02-09 12:15:00', 174, 3), + ('2025-02-10 14:00:00', '2025-02-10 16:15:00', 175, 10), + ('2025-02-11 11:00:00', '2025-02-11 13:15:00', 176, 7), + ('2025-02-12 09:30:00', '2025-02-12 11:30:00', 177, 1), + ('2025-02-13 10:30:00', '2025-02-13 12:45:00', 178, 8), + ('2025-02-14 13:00:00', '2025-02-14 15:15:00', 179, 4), + ('2025-02-15 14:30:00', '2025-02-15 16:45:00', 180, 5), + ('2025-02-16 10:00:00', '2025-02-16 12:15:00', 181, 9), + ('2025-02-17 16:00:00', '2025-02-17 18:30:00', 182, 2), + ('2025-02-18 14:00:00', '2025-02-18 16:00:00', 183, 6), + ('2025-02-19 13:00:00', '2025-02-19 15:00:00', 184, 10), + ('2025-02-20 09:00:00', '2025-02-20 11:15:00', 185, 7), + ('2025-02-21 15:00:00', '2025-02-21 17:15:00', 186, 3), + ('2025-02-22 12:30:00', '2025-02-22 14:45:00', 187, 8), + ('2025-02-23 10:00:00', '2025-02-23 12:30:00', 188, 1), + ('2025-02-24 14:00:00', '2025-02-24 16:00:00', 189, 5), + ('2025-02-25 11:00:00', '2025-02-25 13:00:00', 190, 9), + ('2025-02-26 10:30:00', '2025-02-26 12:45:00', 191, 4), + ('2025-02-27 13:30:00', '2025-02-27 15:30:00', 192, 6), + ('2025-02-28 15:30:00', '2025-02-28 17:30:00', 193, 2), + ('2025-03-01 10:00:00', '2025-03-01 12:15:00', 194, 7), + ('2025-03-02 09:30:00', '2025-03-02 11:30:00', 195, 3), + ('2025-03-03 11:00:00', '2025-03-03 13:15:00', 196, 9), + ('2025-03-04 14:00:00', '2025-03-04 16:15:00', 197, 10), + ('2025-03-05 13:30:00', '2025-03-05 15:45:00', 198, 6), + ('2025-03-06 10:30:00', '2025-03-06 12:45:00', 199, 8), + ('2025-03-07 14:00:00', '2025-03-07 16:00:00', 200, 2), + ('2025-03-08 12:30:00', '2025-03-08 14:45:00', 201, 4), + ('2025-03-09 10:00:00', '2025-03-09 12:15:00', 202, 5), + ('2025-03-10 13:30:00', '2025-03-10 15:45:00', 203, 9), + ('2025-03-11 16:00:00', '2025-03-11 18:15:00', 204, 7), + ('2025-03-12 11:00:00', '2025-03-12 13:15:00', 205, 3), + ('2025-03-13 09:30:00', '2025-03-13 11:45:00', 206, 10), + ('2025-03-14 14:00:00', '2025-03-14 16:15:00', 207, 2), + ('2025-03-15 13:00:00', '2025-03-15 15:00:00', 208, 6), + ('2025-03-16 09:30:00', '2025-03-16 11:30:00', 209, 8), + ('2025-03-17 12:30:00', '2025-03-17 14:45:00', 210, 4), + ('2025-03-18 15:30:00', '2025-03-18 17:30:00', 211, 9), + ('2025-03-19 10:00:00', '2025-03-19 12:15:00', 212, 7), + ('2025-03-20 14:00:00', '2025-03-20 16:00:00', 213, 1), + ('2025-03-21 13:30:00', '2025-03-21 15:30:00', 214, 5), + ('2025-03-22 10:30:00', '2025-03-22 12:30:00', 215, 3), + ('2025-03-23 09:30:00', '2025-03-23 11:45:00', 216, 8), + ('2025-03-24 14:30:00', '2025-03-24 16:30:00', 217, 2), + ('2025-03-25 11:00:00', '2025-03-25 13:15:00', 218, 6), + ('2025-03-26 09:30:00', '2025-03-26 11:30:00', 219, 4), + ('2025-03-27 15:30:00', '2025-03-27 17:30:00', 220, 10), + ('2025-03-28 10:00:00', '2025-03-28 12:30:00', 221, 9), + ('2025-03-29 13:30:00', '2025-03-29 15:30:00', 222, 5), + ('2025-03-30 16:00:00', '2025-03-30 18:15:00', 223, 7), + ('2025-03-30 16:00:00', '2025-03-30 18:15:00', 224, 7); + +INSERT INTO users (name) +VALUES ('정소민'); + +INSERT INTO seat (seat_row, seat_col, screen_room_id) +VALUES + ('ROW_A', 'COL_1', 1), ('ROW_A', 'COL_2', 1), ('ROW_A', 'COL_3', 1), ('ROW_A', 'COL_4', 1), ('ROW_A', 'COL_5', 1), + ('ROW_B', 'COL_1', 1), ('ROW_B', 'COL_2', 1), ('ROW_B', 'COL_3', 1), ('ROW_B', 'COL_4', 1), ('ROW_B', 'COL_5', 1), + ('ROW_C', 'COL_1', 1), ('ROW_C', 'COL_2', 1), ('ROW_C', 'COL_3', 1), ('ROW_C', 'COL_4', 1), ('ROW_C', 'COL_5', 1), + ('ROW_D', 'COL_1', 1), ('ROW_D', 'COL_2', 1), ('ROW_D', 'COL_3', 1), ('ROW_D', 'COL_4', 1), ('ROW_D', 'COL_5', 1), + ('ROW_E', 'COL_1', 1), ('ROW_E', 'COL_2', 1), ('ROW_E', 'COL_3', 1), ('ROW_E', 'COL_4', 1), ('ROW_E', 'COL_5', 1), + ('ROW_A', 'COL_1', 2), ('ROW_A', 'COL_2', 2), ('ROW_A', 'COL_3', 2), ('ROW_A', 'COL_4', 2), ('ROW_A', 'COL_5', 2), + ('ROW_B', 'COL_1', 2), ('ROW_B', 'COL_2', 2), ('ROW_B', 'COL_3', 2), ('ROW_B', 'COL_4', 2), ('ROW_B', 'COL_5', 2), + ('ROW_C', 'COL_1', 2), ('ROW_C', 'COL_2', 2), ('ROW_C', 'COL_3', 2), ('ROW_C', 'COL_4', 2), ('ROW_C', 'COL_5', 2), + ('ROW_D', 'COL_1', 2), ('ROW_D', 'COL_2', 2), ('ROW_D', 'COL_3', 2), ('ROW_D', 'COL_4', 2), ('ROW_D', 'COL_5', 2), + ('ROW_E', 'COL_1', 2), ('ROW_E', 'COL_2', 2), ('ROW_E', 'COL_3', 2), ('ROW_E', 'COL_4', 2), ('ROW_E', 'COL_5', 2), + ('ROW_A', 'COL_1', 3), ('ROW_A', 'COL_2', 3), ('ROW_A', 'COL_3', 3), ('ROW_A', 'COL_4', 3), ('ROW_A', 'COL_5', 3), + ('ROW_B', 'COL_1', 3), ('ROW_B', 'COL_2', 3), ('ROW_B', 'COL_3', 3), ('ROW_B', 'COL_4', 3), ('ROW_B', 'COL_5', 3), + ('ROW_C', 'COL_1', 3), ('ROW_C', 'COL_2', 3), ('ROW_C', 'COL_3', 3), ('ROW_C', 'COL_4', 3), ('ROW_C', 'COL_5', 3), + ('ROW_D', 'COL_1', 3), ('ROW_D', 'COL_2', 3), ('ROW_D', 'COL_3', 3), ('ROW_D', 'COL_4', 3), ('ROW_D', 'COL_5', 3), + ('ROW_E', 'COL_1', 3), ('ROW_E', 'COL_2', 3), ('ROW_E', 'COL_3', 3), ('ROW_E', 'COL_4', 3), ('ROW_E', 'COL_5', 3), + ('ROW_A', 'COL_1', 4), ('ROW_A', 'COL_2', 4), ('ROW_A', 'COL_3', 4), ('ROW_A', 'COL_4', 4), ('ROW_A', 'COL_5', 4), + ('ROW_B', 'COL_1', 4), ('ROW_B', 'COL_2', 4), ('ROW_B', 'COL_3', 4), ('ROW_B', 'COL_4', 4), ('ROW_B', 'COL_5', 4), + ('ROW_C', 'COL_1', 4), ('ROW_C', 'COL_2', 4), ('ROW_C', 'COL_3', 4), ('ROW_C', 'COL_4', 4), ('ROW_C', 'COL_5', 4), + ('ROW_D', 'COL_1', 4), ('ROW_D', 'COL_2', 4), ('ROW_D', 'COL_3', 4), ('ROW_D', 'COL_4', 4), ('ROW_D', 'COL_5', 4), + ('ROW_E', 'COL_1', 4), ('ROW_E', 'COL_2', 4), ('ROW_E', 'COL_3', 4), ('ROW_E', 'COL_4', 4), ('ROW_E', 'COL_5', 4), + ('ROW_A', 'COL_1', 5), ('ROW_A', 'COL_2', 5), ('ROW_A', 'COL_3', 5), ('ROW_A', 'COL_4', 5), ('ROW_A', 'COL_5', 5), + ('ROW_B', 'COL_1', 5), ('ROW_B', 'COL_2', 5), ('ROW_B', 'COL_3', 5), ('ROW_B', 'COL_4', 5), ('ROW_B', 'COL_5', 5), + ('ROW_C', 'COL_1', 5), ('ROW_C', 'COL_2', 5), ('ROW_C', 'COL_3', 5), ('ROW_C', 'COL_4', 5), ('ROW_C', 'COL_5', 5), + ('ROW_D', 'COL_1', 5), ('ROW_D', 'COL_2', 5), ('ROW_D', 'COL_3', 5), ('ROW_D', 'COL_4', 5), ('ROW_D', 'COL_5', 5), + ('ROW_E', 'COL_1', 5), ('ROW_E', 'COL_2', 5), ('ROW_E', 'COL_3', 5), ('ROW_E', 'COL_4', 5), ('ROW_E', 'COL_5', 5), + ('ROW_A', 'COL_1', 6), ('ROW_A', 'COL_2', 6), ('ROW_A', 'COL_3', 6), ('ROW_A', 'COL_4', 6), ('ROW_A', 'COL_5', 6), + ('ROW_B', 'COL_1', 6), ('ROW_B', 'COL_2', 6), ('ROW_B', 'COL_3', 6), ('ROW_B', 'COL_4', 6), ('ROW_B', 'COL_5', 6), + ('ROW_C', 'COL_1', 6), ('ROW_C', 'COL_2', 6), ('ROW_C', 'COL_3', 6), ('ROW_C', 'COL_4', 6), ('ROW_C', 'COL_5', 6), + ('ROW_D', 'COL_1', 6), ('ROW_D', 'COL_2', 6), ('ROW_D', 'COL_3', 6), ('ROW_D', 'COL_4', 6), ('ROW_D', 'COL_5', 6), + ('ROW_E', 'COL_1', 6), ('ROW_E', 'COL_2', 6), ('ROW_E', 'COL_3', 6), ('ROW_E', 'COL_4', 6), ('ROW_E', 'COL_5', 6), + ('ROW_A', 'COL_1', 7), ('ROW_A', 'COL_2', 7), ('ROW_A', 'COL_3', 7), ('ROW_A', 'COL_4', 7), ('ROW_A', 'COL_5', 7), + ('ROW_B', 'COL_1', 7), ('ROW_B', 'COL_2', 7), ('ROW_B', 'COL_3', 7), ('ROW_B', 'COL_4', 7), ('ROW_B', 'COL_5', 7), + ('ROW_C', 'COL_1', 7), ('ROW_C', 'COL_2', 7), ('ROW_C', 'COL_3', 7), ('ROW_C', 'COL_4', 7), ('ROW_C', 'COL_5', 7), + ('ROW_D', 'COL_1', 7), ('ROW_D', 'COL_2', 7), ('ROW_D', 'COL_3', 7), ('ROW_D', 'COL_4', 7), ('ROW_D', 'COL_5', 7), + ('ROW_E', 'COL_1', 7), ('ROW_E', 'COL_2', 7), ('ROW_E', 'COL_3', 7), ('ROW_E', 'COL_4', 7), ('ROW_E', 'COL_5', 7), + ('ROW_A', 'COL_1', 8), ('ROW_A', 'COL_2', 8), ('ROW_A', 'COL_3', 8), ('ROW_A', 'COL_4', 8), ('ROW_A', 'COL_5', 8), + ('ROW_B', 'COL_1', 8), ('ROW_B', 'COL_2', 8), ('ROW_B', 'COL_3', 8), ('ROW_B', 'COL_4', 8), ('ROW_B', 'COL_5', 8), + ('ROW_C', 'COL_1', 8), ('ROW_C', 'COL_2', 8), ('ROW_C', 'COL_3', 8), ('ROW_C', 'COL_4', 8), ('ROW_C', 'COL_5', 8), + ('ROW_D', 'COL_1', 8), ('ROW_D', 'COL_2', 8), ('ROW_D', 'COL_3', 8), ('ROW_D', 'COL_4', 8), ('ROW_D', 'COL_5', 8), + ('ROW_E', 'COL_1', 8), ('ROW_E', 'COL_2', 8), ('ROW_E', 'COL_3', 8), ('ROW_E', 'COL_4', 8), ('ROW_E', 'COL_5', 8), + ('ROW_A', 'COL_1', 9), ('ROW_A', 'COL_2', 9), ('ROW_A', 'COL_3', 9), ('ROW_A', 'COL_4', 9), ('ROW_A', 'COL_5', 9), + ('ROW_B', 'COL_1', 9), ('ROW_B', 'COL_2', 9), ('ROW_B', 'COL_3', 9), ('ROW_B', 'COL_4', 9), ('ROW_B', 'COL_5', 9), + ('ROW_C', 'COL_1', 9), ('ROW_C', 'COL_2', 9), ('ROW_C', 'COL_3', 9), ('ROW_C', 'COL_4', 9), ('ROW_C', 'COL_5', 9), + ('ROW_D', 'COL_1', 9), ('ROW_D', 'COL_2', 9), ('ROW_D', 'COL_3', 9), ('ROW_D', 'COL_4', 9), ('ROW_D', 'COL_5', 9), + ('ROW_E', 'COL_1', 9), ('ROW_E', 'COL_2', 9), ('ROW_E', 'COL_3', 9), ('ROW_E', 'COL_4', 9), ('ROW_E', 'COL_5', 9), + ('ROW_A', 'COL_1', 10), ('ROW_A', 'COL_2', 10), ('ROW_A', 'COL_3', 10), ('ROW_A', 'COL_4', 10), ('ROW_A', 'COL_5', 10), + ('ROW_B', 'COL_1', 10), ('ROW_B', 'COL_2', 10), ('ROW_B', 'COL_3', 10), ('ROW_B', 'COL_4', 10), ('ROW_B', 'COL_5', 10), + ('ROW_C', 'COL_1', 10), ('ROW_C', 'COL_2', 10), ('ROW_C', 'COL_3', 10), ('ROW_C', 'COL_4', 10), ('ROW_C', 'COL_5', 10), + ('ROW_D', 'COL_1', 10), ('ROW_D', 'COL_2', 10), ('ROW_D', 'COL_3', 10), ('ROW_D', 'COL_4', 10), ('ROW_D', 'COL_5', 10), + ('ROW_E', 'COL_1', 10), ('ROW_E', 'COL_2', 10), ('ROW_E', 'COL_3', 10), ('ROW_E', 'COL_4', 10), ('ROW_E', 'COL_5', 10); \ No newline at end of file diff --git a/module-api/src/test/java/org/example/ApiApplicationTest.java b/module-api/src/test/java/org/example/ApiApplicationTest.java new file mode 100644 index 000000000..7cf2f49fd --- /dev/null +++ b/module-api/src/test/java/org/example/ApiApplicationTest.java @@ -0,0 +1,14 @@ +package org.example; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +class ApiApplicationTest { + + @Test + void contextLoads() { + } +} \ No newline at end of file diff --git a/module-api/src/test/java/org/example/RateLimiterTest.java b/module-api/src/test/java/org/example/RateLimiterTest.java new file mode 100644 index 000000000..bd597fa5c --- /dev/null +++ b/module-api/src/test/java/org/example/RateLimiterTest.java @@ -0,0 +1,122 @@ +package org.example; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.http.*; +import org.springframework.test.context.ActiveProfiles; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class RateLimiterTest { + @LocalServerPort + private int port; + + @Autowired + private TestRestTemplate restTemplate; + + @Autowired + private StringRedisTemplate redisTemplate; + + private static final String TEST_IP = "127.0.0.1"; + + @AfterEach + void cleanup() { + redisTemplate.delete("request:" + TEST_IP); + redisTemplate.delete("block:" + TEST_IP); + } + + @Test + void testGuavaRateLimiter_BlocksExcessRequests() throws Exception { + String url = "http://localhost:" + port + "/movies/playing"; + + // 첫 번째 요청 + ResponseEntity response1 = restTemplate.getForEntity(url, String.class); + assertEquals(HttpStatus.OK, response1.getStatusCode()); + + // 두 번째 요청 + ResponseEntity response2 = restTemplate.getForEntity(url, String.class); + assertEquals(HttpStatus.OK, response2.getStatusCode()); + + // 세 번째 요청 + ResponseEntity response3 = restTemplate.getForEntity(url, String.class); + assertEquals(HttpStatus.TOO_MANY_REQUESTS, response3.getStatusCode()); + } + + @Test + void testRedisRateLimit_UnderLimit() { + String url = "http://localhost:" + port + "/movies/playing"; + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Forwarded-For", TEST_IP); + + for (int i = 0; i < 50; i++) { + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.GET, + new HttpEntity<>(headers), + String.class + ); + assertEquals(HttpStatus.OK, response.getStatusCode()); + } + } + + @Test + void testRedisRateLimit_ExceedLimit() { + String url = "http://localhost:" + port + "/movies/playing"; + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Forwarded-For", TEST_IP); + + // 49번 요청 실행 + for (int i = 0; i < 50; i++) { + restTemplate.exchange( + url, + HttpMethod.GET, + new HttpEntity<>(headers), + String.class + ); + } + + // 50번째 요청은 차단되어야 함 + ResponseEntity blockedResponse = restTemplate.exchange( + url, + HttpMethod.GET, + new HttpEntity<>(headers), + String.class + ); + assertEquals(HttpStatus.TOO_MANY_REQUESTS, blockedResponse.getStatusCode()); + } + + @Test + void testRedisRateLimit_Unblock() throws InterruptedException { + String url = "http://localhost:" + port + "/movies/playing"; + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Forwarded-For", TEST_IP); + + // 제한 초과 + for (int i = 0; i < 50; i++) { + restTemplate.exchange( + url, + HttpMethod.GET, + new HttpEntity<>(headers), + String.class + ); + } + + // 테스트를 위해 block이 해제되는 시간을 1분 후로 설정 + Thread.sleep(60 * 1000); + ResponseEntity responseAfterOneMin = restTemplate.exchange( + url, + HttpMethod.GET, + new HttpEntity<>(headers), + String.class + ); + assertEquals(HttpStatus.OK, responseAfterOneMin.getStatusCode()); + } +} \ No newline at end of file diff --git a/module-api/src/test/java/org/example/ReservationConcurrencyTest.java b/module-api/src/test/java/org/example/ReservationConcurrencyTest.java new file mode 100644 index 000000000..194a98271 --- /dev/null +++ b/module-api/src/test/java/org/example/ReservationConcurrencyTest.java @@ -0,0 +1,65 @@ +package org.example; + +import org.assertj.core.api.Assertions; +import org.example.dto.request.ReservationRequestDto; +import org.example.dto.request.ReservationSeatDto; +import org.example.repository.ReservationSeatRepository; +import org.example.service.reservation.ReservationService; +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.ActiveProfiles; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@SpringBootTest +@ActiveProfiles("test") +public class ReservationConcurrencyTest { + @Autowired + private ReservationService reservationService; + + @Autowired + private ReservationSeatRepository reservationSeatRepository; + + @Test + void testConcurrentReservation() throws InterruptedException { + int numberOfThreads = 100; + ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads); + CountDownLatch latch = new CountDownLatch(numberOfThreads); + + List reservationSeatDtos = new ArrayList<>(); + reservationSeatDtos.add(new ReservationSeatDto("ROW_A", "COL_1")); + reservationSeatDtos.add(new ReservationSeatDto("ROW_A", "COL_2")); + + List reservationSeatDtos2 = new ArrayList<>(); + reservationSeatDtos2.add(new ReservationSeatDto("ROW_A", "COL_2")); + reservationSeatDtos2.add(new ReservationSeatDto("ROW_A", "COL_3")); + + + for (long i = 0; i < numberOfThreads; i++) { + ReservationRequestDto reservationRequestDto = new ReservationRequestDto(i, 2L, reservationSeatDtos); + ReservationRequestDto reservationRequestDto2 = new ReservationRequestDto(i+100, 2L, reservationSeatDtos2); + + executorService.execute(() -> { + try { + reservationService.reserveMovie(reservationRequestDto); + reservationService.reserveMovie(reservationRequestDto2); + } catch (Exception e) { + System.out.println(e.getMessage()); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + List reservedSeats = reservationSeatRepository.findReservedSeatByScreenScheduleId(2L); + Assertions.assertThat(reservedSeats.size()).isEqualTo(2); + } +} diff --git a/module-api/src/test/java/org/example/controller/MovieControllerTest.java b/module-api/src/test/java/org/example/controller/MovieControllerTest.java new file mode 100644 index 000000000..109036d64 --- /dev/null +++ b/module-api/src/test/java/org/example/controller/MovieControllerTest.java @@ -0,0 +1,45 @@ +package org.example.controller; + +import org.example.baseresponse.BaseResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.http.*; +import org.springframework.test.context.ActiveProfiles; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class MovieControllerTest { + @LocalServerPort + private int port; + + @Autowired + private TestRestTemplate restTemplate; + + private static final String TEST_IP = "127.0.0.1"; + + @Test + @DisplayName("상영 중인 영화 리스트를 조회한다.") + void searchPlayingMovies_Success() { + String url = "http://localhost:" + port + "/movies/playing?movieTitle=Inception&genre=SF"; + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Forwarded-For", TEST_IP); + + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.GET, + new HttpEntity<>(headers), + BaseResponse.class + ); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertNotNull(response.getBody()); + } +} \ No newline at end of file diff --git a/module-api/src/test/java/org/example/controller/ReservationControllerTest.java b/module-api/src/test/java/org/example/controller/ReservationControllerTest.java new file mode 100644 index 000000000..b0a64e1eb --- /dev/null +++ b/module-api/src/test/java/org/example/controller/ReservationControllerTest.java @@ -0,0 +1,425 @@ +package org.example.controller; + +import org.example.baseresponse.BaseResponse; +import org.example.baseresponse.error.BaseErrorResponse; +import org.example.dto.request.ReservationRequestDto; +import org.example.dto.request.ReservationSeatDto; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.Objects; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class ReservationControllerTest { + @LocalServerPort + private int port; + + @Autowired + private TestRestTemplate restTemplate; + + @DisplayName("예매 성공 테스트") + @Nested + class reserveMovie_Success { + @Test + @DisplayName("좌석 1개 예매할 때 예매에 성공한다.") + void reserveMovie_Success_1seat() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 1L, 1L, List.of(new ReservationSeatDto("ROW_A", "COL_1")) + ); + + ResponseEntity response = restTemplate.postForEntity( + url, requestDto, BaseResponse.class + ); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(1000, Objects.requireNonNull(response.getBody()).getCode()); + } + + @Test + @DisplayName("좌석 5개 예매할 때 예매에 성공한다.") + void reserveMovie_Success_5seat() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 2L, + 1L, + List.of(new ReservationSeatDto("ROW_B", "COL_1"), + new ReservationSeatDto("ROW_B", "COL_2"), + new ReservationSeatDto("ROW_B", "COL_3"), + new ReservationSeatDto("ROW_B", "COL_4"), + new ReservationSeatDto("ROW_B", "COL_5")) + ); + + ResponseEntity response = restTemplate.postForEntity( + url, requestDto, BaseResponse.class + ); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(1000, Objects.requireNonNull(response.getBody()).getCode()); + } + } + + @DisplayName("입력이 올바르지 않을 때 예매 실패 테스트") + @Nested + class reserveMovie_Fail_Null { + @Test + @DisplayName("userId가 null일 을 예매 실패") + void reserveMovie_Fail_Null_UserId() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + null, 2L, List.of(new ReservationSeatDto("ROW_A", "COL_1")) + ); + + ResponseEntity response = restTemplate.postForEntity( + url, requestDto, BaseResponse.class + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + @DisplayName("screenScheduleId가 null일 때 예매 실패") + void reserveMovie_Fail_Null_ScreenScheduleId() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 3L, null, List.of(new ReservationSeatDto("ROW_A", "COL_1")) + ); + + ResponseEntity response = restTemplate.postForEntity( + url, requestDto, BaseResponse.class + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + @DisplayName("좌석이 null일 때 예매 실패") + void reserveMovie_Fail_Null_Seats() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 3L, 3L, null + ); + + ResponseEntity response = restTemplate.postForEntity( + url, requestDto, BaseResponse.class + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + } + + @DisplayName("유효하지 않은 좌석 번호일 때 예매 실패 테스트") + @Nested + class reserveMovie_Fail_Seat { + @Test + @DisplayName("유효하지 않은 좌석 번호일 때 예매 실패") + void reserveMovie_Fail_InvalidSeat() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 4L, 4L, List.of(new ReservationSeatDto("A", "1")) + ); + + ResponseEntity response = restTemplate.postForEntity( + url, requestDto, BaseResponse.class + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + @DisplayName("유효하지 않은 열 번호로 예매 실패") + void reserveMovie_Fail_InvalidCol() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 4L, 4L, List.of(new ReservationSeatDto("ROW_A", "COL_6")) + ); + + ResponseEntity response = restTemplate.postForEntity( + url, requestDto, BaseResponse.class + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + @DisplayName("유효하지 않은 행 번호로 예매 실패") + void reserveMovie_Fail_InvalidRow() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 4L, 4L, List.of(new ReservationSeatDto("ROW_F", "COL_1")) + ); + + ResponseEntity response = restTemplate.postForEntity( + url, requestDto, BaseResponse.class + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + } + + @DisplayName("예매하려는 좌석이 올바르지 않을 때 예매 실패 테스트") + @Nested + class reserveMovie_Fail_SeatCount { + @Test + @DisplayName("예매하려는 좌석 개수가 0개 때 예매 실패") + void reserveMovie_Fail_EmptySeats() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 5L, + 5L, + List.of() + ); + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + new HttpEntity<>(requestDto), + new ParameterizedTypeReference() {} + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertEquals("예약 가능한 좌석 개수가 아닙니다.", response.getBody().getMessage()); + } + + @Test + @DisplayName("예매 가능한 좌석 개수를 초과했을 때 예매 실패") + void reserveMovie_Fail_ExceedSeatCount() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 5L, + 5L, + List.of(new ReservationSeatDto("ROW_A", "COL_1"), + new ReservationSeatDto("ROW_A", "COL_2"), + new ReservationSeatDto("ROW_A", "COL_3"), + new ReservationSeatDto("ROW_A", "COL_4"), + new ReservationSeatDto("ROW_A", "COL_5"), + new ReservationSeatDto("ROW_B", "COL_1")) + ); + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + new HttpEntity<>(requestDto), + new ParameterizedTypeReference() {} + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertEquals("예약 가능한 좌석 개수가 아닙니다.", response.getBody().getMessage()); + } + + @Test + @DisplayName("좌석이 같은 행이 아니면 예매 실패") + void reserveMovie_Fail_SameRow() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 6L, + 6L, + List.of(new ReservationSeatDto("ROW_A", "COL_1"), + new ReservationSeatDto("ROW_B", "COL_2"), + new ReservationSeatDto("ROW_C", "COL_3")) + ); + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + new HttpEntity<>(requestDto), + new ParameterizedTypeReference() {} + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertEquals("연속된 좌석만 예약할 수 있습니다. 행이 다릅니다.", response.getBody().getMessage()); + } + + @Test + @DisplayName("좌석이 연속된 열이 아니면 예매 실패") + void reserveMovie_Fail_ContinuousCol() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 6L, + 6L, + List.of(new ReservationSeatDto("ROW_A", "COL_1"), + new ReservationSeatDto("ROW_A", "COL_3"), + new ReservationSeatDto("ROW_A", "COL_4")) + ); + + ResponseEntity response = restTemplate.exchange( + url, + HttpMethod.POST, + new HttpEntity<>(requestDto), + new ParameterizedTypeReference() {} + ); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + assertEquals("연속된 좌석만 예약할 수 있습니다. 열이 연속되지 않았습니다.", response.getBody().getMessage()); + } + } + + @DisplayName("같은 사용자 좌석 예매 실패 테스트") + @Nested + class reserveMovie_Fail_SameUser { + @Test + @DisplayName("같은 사용자가 5자리 이상 예매 시도할 경우 예매 실패") + void reserveMovie_Fail_SameUser_ExceedSeatCount() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 7L, + 7L, + List.of(new ReservationSeatDto("ROW_A", "COL_1"), + new ReservationSeatDto("ROW_A", "COL_2"), + new ReservationSeatDto("ROW_A", "COL_3")) + ); + + ReservationRequestDto requestDto2 = new ReservationRequestDto( + 7L, + 7L, + List.of(new ReservationSeatDto("ROW_B", "COL_1"), + new ReservationSeatDto("ROW_B", "COL_2"), + new ReservationSeatDto("ROW_B", "COL_3")) + ); + + restTemplate.postForEntity( + url, requestDto, BaseResponse.class + ); + + ResponseEntity response2 = restTemplate.exchange( + url, + HttpMethod.POST, + new HttpEntity<>(requestDto2), + new ParameterizedTypeReference() {} + ); + + assertEquals(HttpStatus.BAD_REQUEST, response2.getStatusCode()); + assertEquals("최대 예약 가능한 좌석을 초과했습니다.", response2.getBody().getMessage()); + } + + @Test + @DisplayName("같은 사용자가 다른 행의 좌석을 예매 시도할 경우 예매 실패") + void reserveMovie_Fail_SameUser_IsNotSameRow() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 8L, + 8L, + List.of(new ReservationSeatDto("ROW_A", "COL_1"), + new ReservationSeatDto("ROW_A", "COL_2")) + ); + + ReservationRequestDto requestDto2 = new ReservationRequestDto( + 8L, + 8L, + List.of(new ReservationSeatDto("ROW_B", "COL_1"), + new ReservationSeatDto("ROW_B", "COL_2")) + ); + + restTemplate.postForEntity( + url, requestDto, BaseResponse.class + ); + + ResponseEntity response2 = restTemplate.exchange( + url, + HttpMethod.POST, + new HttpEntity<>(requestDto2), + new ParameterizedTypeReference() {} + ); + + assertEquals(HttpStatus.BAD_REQUEST, response2.getStatusCode()); + assertEquals("연속된 좌석만 예약할 수 있습니다. 행이 다릅니다.", response2.getBody().getMessage()); + } + + @Test + @DisplayName("같은 사용자가 연속되지 않는 열의 좌석을 예매 시도할 경우 예매 실패") + void reserveMovie_Fail_SameUser_IsNotContinuousCol() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 9L, + 9L, + List.of(new ReservationSeatDto("ROW_A", "COL_1"), + new ReservationSeatDto("ROW_A", "COL_2")) + ); + + ReservationRequestDto requestDto2 = new ReservationRequestDto( + 9L, + 9L, + List.of(new ReservationSeatDto("ROW_A", "COL_4"), + new ReservationSeatDto("ROW_A", "COL_5")) + ); + + restTemplate.postForEntity( + url, requestDto, BaseResponse.class + ); + + ResponseEntity response2 = restTemplate.exchange( + url, + HttpMethod.POST, + new HttpEntity<>(requestDto2), + new ParameterizedTypeReference() {} + ); + + assertEquals(HttpStatus.BAD_REQUEST, response2.getStatusCode()); + assertEquals("연속된 좌석만 예약할 수 있습니다. 열이 연속되지 않았습니다.", response2.getBody().getMessage()); + } + + @Test + @DisplayName("같은 사용자가 이미 예매한 좌석을 예매 시도할 경우 예매 실패") + void reserveMovie_Fail_SameUser_AlreadyReserved() { + String url = "http://localhost:" + port + "/reservation"; + + ReservationRequestDto requestDto = new ReservationRequestDto( + 10L, + 10L, + List.of(new ReservationSeatDto("ROW_A", "COL_1"), + new ReservationSeatDto("ROW_A", "COL_2")) + ); + + ReservationRequestDto requestDto2 = new ReservationRequestDto( + 10L, + 10L, + List.of(new ReservationSeatDto("ROW_A", "COL_1"), + new ReservationSeatDto("ROW_A", "COL_2")) + ); + + restTemplate.postForEntity( + url, requestDto, BaseResponse.class + ); + + ResponseEntity response2 = restTemplate.exchange( + url, + HttpMethod.POST, + new HttpEntity<>(requestDto2), + new ParameterizedTypeReference() {} + ); + + assertEquals(HttpStatus.BAD_REQUEST, response2.getStatusCode()); + assertEquals("이미 예약된 좌석입니다.", response2.getBody().getMessage()); + } + } +} \ No newline at end of file diff --git a/module-api/src/test/java/org/example/service/RateLimiterServiceTest.java b/module-api/src/test/java/org/example/service/RateLimiterServiceTest.java new file mode 100644 index 000000000..50de5a615 --- /dev/null +++ b/module-api/src/test/java/org/example/service/RateLimiterServiceTest.java @@ -0,0 +1,49 @@ +package org.example.service; + +import com.google.common.util.concurrent.RateLimiter; +import org.example.service.movie.FindMovieService; +import org.example.service.movie.MovieService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockitoAnnotations; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ActiveProfiles("test") +public class RateLimiterServiceTest { + @MockBean + private FindMovieService findMovieService; + + private MovieService movieService; + + private RateLimiter rateLimiter; + + @BeforeEach + void setUp() throws InterruptedException { + MockitoAnnotations.openMocks(this); + rateLimiter = RateLimiter.create(2.0); // 초당 2개의 요청을 허용하는 RateLimiter + movieService = new MovieService(findMovieService, rateLimiter); + Thread.sleep(500); + } + + @Test + void testRateLimiter_AllowsOnlyTwoRequestsPerSecond() { + assertTrue(movieService.rateLimiter.tryAcquire()); // 첫 번째 요청 허용 + assertTrue(movieService.rateLimiter.tryAcquire()); // 두 번째 요청 허용 + assertFalse(movieService.rateLimiter.tryAcquire()); // 세 번째 요청 거부 + } + + @Test + void testRateLimiter_AllowsRequestAfterDelay() throws InterruptedException { + assertTrue(movieService.rateLimiter.tryAcquire()); // 첫 번째 요청 허용 + assertTrue(movieService.rateLimiter.tryAcquire()); // 두 번째 요청 허용 + assertFalse(movieService.rateLimiter.tryAcquire()); // 세 번째 요청 거부 + + Thread.sleep(1000); // 1초 후 토큰이 다시 충전됨 + + assertTrue(movieService.rateLimiter.tryAcquire()); // 다시 요청 가능 + } +} diff --git a/module-api/src/test/java/org/example/service/reservation/ReservationServiceTest.java b/module-api/src/test/java/org/example/service/reservation/ReservationServiceTest.java new file mode 100644 index 000000000..1dc7b9d6b --- /dev/null +++ b/module-api/src/test/java/org/example/service/reservation/ReservationServiceTest.java @@ -0,0 +1,157 @@ +package org.example.service.reservation; + +import org.example.domain.reservation.Reservation; +import org.example.domain.reservationseat.ReservationSeat; +import org.example.domain.seat.Col; +import org.example.domain.seat.Row; +import org.example.domain.seat.Seat; +import org.example.dto.SeatsDto; +import org.example.exception.SeatException; +import org.example.repository.ReservationJpaRepository; +import org.example.repository.ReservationSeatRepository; +import org.example.repository.SeatJpaRepository; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatchers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.context.ActiveProfiles; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.example.baseresponse.BaseResponseStatus.CONCURRENT_RESERVATION_ERROR; +import static org.example.baseresponse.BaseResponseStatus.UNAVAILABLE_SEAT_ERROR; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@ActiveProfiles("test") +class ReservationServiceTest { + @InjectMocks + private SaveReservationService saveReservationService; + + @InjectMocks + private ReservationService reservationService; + + @Mock + private ReservationJpaRepository reservationJpaRepository; + + @Mock + private ReservationSeatRepository reservationSeatRepository; + + @Mock + private SeatJpaRepository seatJpaRepository; + + + @Test + @DisplayName("예약된 좌석이 없는 경우 예약 성공") + void reserve_Success() { + // Given + Long userId = 1L; + Long screenScheduleId = 1L; + List seats = new ArrayList<>(); + seats.add(Seat.of(1L, Row.ROW_A, Col.COL_1, 1L)); + seats.add(Seat.of(2L, Row.ROW_A, Col.COL_2, 1L)); + + // Mock: 좌석이 예약되지 않았음을 가정 + when(reservationSeatRepository.findReservedSeatBySeatId(anyLong(), anyLong())) + .thenReturn(Optional.empty()); // 예약된 좌석 없음 + + Reservation reservation = new Reservation(userId, screenScheduleId); + when(reservationJpaRepository.save(ArgumentMatchers.any())) + .thenReturn(reservation); + + // When + saveReservationService.saveReservationWithTransaction(userId, screenScheduleId, seats); + + // Then + verify(reservationJpaRepository, times(1)).save(ArgumentMatchers.any()); + verify(reservationSeatRepository, times(2)).save(ArgumentMatchers.any()); + } + + @Test + @DisplayName("예약된 좌석이 없는 경우 예외 발생") + void reserve_Fail() { + // Given + Long userId = 1L; + Long screenScheduleId = 1L; + List seats = new ArrayList<>(); + seats.add(Seat.of(1L, Row.ROW_A, Col.COL_1, 1L)); + seats.add(Seat.of(2L, Row.ROW_A, Col.COL_2, 1L)); + + // Mock: 첫 번째 좌석이 이미 예약됨 + when(reservationSeatRepository.findReservedSeatBySeatId(screenScheduleId, 1L)) + .thenReturn(Optional.of(new ReservationSeat(1L, 1L))); + + // When & Then + SeatException thrown = assertThrows(SeatException.class, () -> + saveReservationService.saveReservationWithTransaction(userId, screenScheduleId, seats) + ); + + Assertions.assertEquals(CONCURRENT_RESERVATION_ERROR, thrown.getExceptionStatus()); + verify(reservationJpaRepository, never()).save(ArgumentMatchers.any()); // 예약이 저장되지 않아야 함 + verify(reservationSeatRepository, never()).save(ArgumentMatchers.any()); // 좌석도 저장되지 않아야 함 + } + + @Test + @DisplayName("Seat 테이블에 좌석이 존재하면 조회에 성공한다.") + void getSeat_Success() { + // Given + Long screenRoomId = 1L; + List reservationSeats = List.of( + new SeatsDto(Row.ROW_A, Col.COL_1), + new SeatsDto(Row.ROW_A, Col.COL_2) + ); + + // Mock: 존재하는 좌석 설정 + given(seatJpaRepository.findSeats(screenRoomId, Row.ROW_A, Col.COL_1)) + .willReturn(Optional.of(new Seat(1L,Row.ROW_A, Col.COL_1, screenRoomId))); + + given(seatJpaRepository.findSeats(screenRoomId, Row.ROW_A, Col.COL_2)) + .willReturn(Optional.of(new Seat(2L, Row.ROW_A, Col.COL_2, screenRoomId))); + + // When + List seats = reservationService.validateReservedSeats(screenRoomId, reservationSeats); + + // Then + Assertions.assertEquals(2, seats.size()); + Assertions.assertEquals("A", seats.get(0).getRow().getRow()); + Assertions.assertEquals(1, seats.get(0).getCol().getColumn()); + Assertions.assertEquals("A", seats.get(1).getRow().getRow()); + Assertions.assertEquals(2, seats.get(1).getCol().getColumn()); + } + + @Test + @DisplayName("Seat 테이블에 좌석이 존재하지 않으면 예외가 발생한다.") + void getSeat_Fail() { + // Given + Long screenRoomId = 1L; + List reservationSeats = List.of( + new SeatsDto(Row.ROW_A, Col.COL_1), + new SeatsDto(Row.ROW_A, Col.COL_2) + ); + + // Mock: 존재하는 좌석 설정 + given(seatJpaRepository.findSeats(screenRoomId, Row.ROW_A, Col.COL_1)) + .willReturn(Optional.of(new Seat(1L,Row.ROW_A, Col.COL_1, screenRoomId))); + + given(seatJpaRepository.findSeats(screenRoomId, Row.ROW_A, Col.COL_2)) + .willReturn(Optional.empty()); + + // When & Then + SeatException thrown = assertThrows(SeatException.class, () -> + reservationService.validateReservedSeats(screenRoomId, reservationSeats) + ); + + Assertions.assertEquals(UNAVAILABLE_SEAT_ERROR, thrown.getExceptionStatus()); + } + + +} \ No newline at end of file diff --git a/module-api/src/test/resources/application.yml b/module-api/src/test/resources/application.yml new file mode 100644 index 000000000..5636a4738 --- /dev/null +++ b/module-api/src/test/resources/application.yml @@ -0,0 +1,34 @@ +spring: + config: + activate: + on-profile: test + + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://localhost:3305/movie?allowPublicKeyRetrieval=true&useSSL=false + username: root + password: wjdthals1104 + + jpa: + show-sql: true + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.MySQLDialect + hibernate: + ddl-auto: none + defer-datasource-initialization: true + + cache: + type: redis + + data: + redis: + host: localhost + port: 6378 + + sql: + init: + mode: embedded + schema-locations: classpath:data.sql \ No newline at end of file diff --git a/module-common/build.gradle b/module-common/build.gradle new file mode 100644 index 000000000..219442953 --- /dev/null +++ b/module-common/build.gradle @@ -0,0 +1,10 @@ +plugins { + id 'java' +} + +jar.enabled = true + +dependencies { + testImplementation platform('org.junit:junit-bom:5.9.1') + testImplementation 'org.junit.jupiter:junit-jupiter' +} \ No newline at end of file diff --git a/module-common/src/main/generated/org/example/entity/QBaseEntity.java b/module-common/src/main/generated/org/example/entity/QBaseEntity.java new file mode 100644 index 000000000..955b99a2f --- /dev/null +++ b/module-common/src/main/generated/org/example/entity/QBaseEntity.java @@ -0,0 +1,43 @@ +package org.example.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QBaseEntity is a Querydsl query type for BaseEntity + */ +@Generated("com.querydsl.codegen.DefaultSupertypeSerializer") +public class QBaseEntity extends EntityPathBase { + + private static final long serialVersionUID = -1461025007L; + + public static final QBaseEntity baseEntity = new QBaseEntity("baseEntity"); + + public final DateTimePath createdDate = createDateTime("createdDate", java.time.LocalDateTime.class); + + public final StringPath lastModifiedBy = createString("lastModifiedBy"); + + public final DateTimePath lastModifiedDate = createDateTime("lastModifiedDate", java.time.LocalDateTime.class); + + public final StringPath updatedBy = createString("updatedBy"); + + public QBaseEntity(String variable) { + super(BaseEntity.class, forVariable(variable)); + } + + public QBaseEntity(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QBaseEntity(PathMetadata metadata) { + super(BaseEntity.class, metadata); + } + +} + diff --git a/module-common/src/main/java/org/example/annotaion/EnumValidator.java b/module-common/src/main/java/org/example/annotaion/EnumValidator.java new file mode 100644 index 000000000..e8c3f9d3a --- /dev/null +++ b/module-common/src/main/java/org/example/annotaion/EnumValidator.java @@ -0,0 +1,29 @@ +package org.example.annotaion; + +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/module-common/src/main/java/org/example/annotaion/ValidEnum.java b/module-common/src/main/java/org/example/annotaion/ValidEnum.java new file mode 100644 index 000000000..cfccad8b2 --- /dev/null +++ b/module-common/src/main/java/org/example/annotaion/ValidEnum.java @@ -0,0 +1,18 @@ +package org.example.annotaion; + +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(); +} \ No newline at end of file diff --git a/module-common/src/main/java/org/example/baseresponse/BaseResponse.java b/module-common/src/main/java/org/example/baseresponse/BaseResponse.java new file mode 100644 index 000000000..c7fb51e63 --- /dev/null +++ b/module-common/src/main/java/org/example/baseresponse/BaseResponse.java @@ -0,0 +1,44 @@ +package org.example.baseresponse; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; + +import static org.example.baseresponse.BaseResponseStatus.SUCCESS; + +@Getter +@JsonPropertyOrder({"code", "status", "message", "result"}) +public class BaseResponse implements ResponseStatus { + private final int code; + private final int status; + private final String message; + + @JsonInclude(JsonInclude.Include.NON_NULL) + private final T result; + + @JsonCreator + public BaseResponse(T result){ + this.code = SUCCESS.getCode(); + this.status = SUCCESS.getStatus(); + this.message = SUCCESS.getMessage(); + this.result = result; + } + + @Override + public int getCode() { + return code; + } + + @Override + public int getStatus() { + return status; + } + + @Override + public String getMessage() { + return message; + } +} \ No newline at end of file diff --git a/module-common/src/main/java/org/example/baseresponse/BaseResponseStatus.java b/module-common/src/main/java/org/example/baseresponse/BaseResponseStatus.java new file mode 100644 index 000000000..ceb1e9e1a --- /dev/null +++ b/module-common/src/main/java/org/example/baseresponse/BaseResponseStatus.java @@ -0,0 +1,43 @@ +package org.example.baseresponse; + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@RequiredArgsConstructor +public enum BaseResponseStatus implements ResponseStatus { + /** + * 1000: 요청 성공 (OK) + */ + SUCCESS(1000, HttpStatus.OK.value(), "요청에 성공하였습니다."), + + /** + * 5000: 좌석 정보 오류 + */ + UNAVAILABLE_SEAT_ERROR(5001, HttpStatus.BAD_REQUEST.value(), "예약할 수 없는 좌석입니다."), + CONCURRENT_RESERVATION_ERROR(5002, HttpStatus.BAD_REQUEST.value(), "다른 사용자가 이미 예약을 진행 중입니다. 다시 시도해 주세요."), + MAX_SEATS_EXCEEDED_ERROR(5003, HttpStatus.BAD_REQUEST.value(), "최대 예약 가능한 좌석을 초과했습니다."), + SEAT_ROW_DISCONTINUITY_ERROR(5004, HttpStatus.BAD_REQUEST.value(), "연속된 좌석만 예약할 수 있습니다. 행이 다릅니다."), + SEAT_COLUMN_DISCONTINUITY_ERROR(5005, HttpStatus.BAD_REQUEST.value(), "연속된 좌석만 예약할 수 있습니다. 열이 연속되지 않았습니다."), + ALREADY_RESERVED_SEAT_ERROR(5006, HttpStatus.BAD_REQUEST.value(), "이미 예약된 좌석입니다."), + TOO_MANY_REQUEST_ERROR(5007, HttpStatus.TOO_MANY_REQUESTS.value(), "너무 많은 요청이 들어왔습니다. 나중에 다시 시도해주세요."), + UNAVAILABLE_SEAT_COUNT_ERROR(5008, HttpStatus.BAD_REQUEST.value(), "예약 가능한 좌석 개수가 아닙니다."); + + private final int code; + private final int status; + private final String message; + + @Override + public int getCode() { + return code; + } + + @Override + public int getStatus() { + return status; + } + + @Override + public String getMessage() { + return message; + } +} diff --git a/module-common/src/main/java/org/example/baseresponse/ResponseStatus.java b/module-common/src/main/java/org/example/baseresponse/ResponseStatus.java new file mode 100644 index 000000000..fd3f48b21 --- /dev/null +++ b/module-common/src/main/java/org/example/baseresponse/ResponseStatus.java @@ -0,0 +1,7 @@ +package org.example.baseresponse; + +public interface ResponseStatus { + int getCode(); + int getStatus(); + String getMessage(); +} diff --git a/module-common/src/main/java/org/example/baseresponse/error/BaseErrorResponse.java b/module-common/src/main/java/org/example/baseresponse/error/BaseErrorResponse.java new file mode 100644 index 000000000..5230b317a --- /dev/null +++ b/module-common/src/main/java/org/example/baseresponse/error/BaseErrorResponse.java @@ -0,0 +1,39 @@ +package org.example.baseresponse.error; + +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import org.example.baseresponse.ResponseStatus; + +@Getter +@Setter +@NoArgsConstructor +@JsonPropertyOrder({"code", "status", "message", "timestamp"}) +public class BaseErrorResponse implements ResponseStatus { + private int code; + private int status; + private String message; + + public BaseErrorResponse(ResponseStatus status){ + this.code = status.getCode(); + this.status = status.getStatus(); + this.message = status.getMessage(); + } + + @Override + public int getCode() { + return code; + } + + @Override + public int getStatus() { + return status; + } + + @Override + public String getMessage() { + return message; + } +} diff --git a/module-common/src/main/java/org/example/config/CacheConfig.java b/module-common/src/main/java/org/example/config/CacheConfig.java new file mode 100644 index 000000000..47c92935a --- /dev/null +++ b/module-common/src/main/java/org/example/config/CacheConfig.java @@ -0,0 +1,22 @@ +package org.example.config; + +import org.redisson.api.RedissonClient; +import org.redisson.spring.cache.RedissonSpringCacheManager; +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; + +@Configuration +@EnableCaching +public class CacheConfig { + @Bean + public CacheManager cacheManager(RedissonClient redissonClient) { + Map configMap = new HashMap<>(); + configMap.put("playingMovies", new org.redisson.spring.cache.CacheConfig(0, 0)); // 영구 저장 + return new RedissonSpringCacheManager(redissonClient, configMap); + } +} diff --git a/module-common/src/main/java/org/example/config/JpaAuditingConfig.java b/module-common/src/main/java/org/example/config/JpaAuditingConfig.java new file mode 100644 index 000000000..331d3a2eb --- /dev/null +++ b/module-common/src/main/java/org/example/config/JpaAuditingConfig.java @@ -0,0 +1,9 @@ +package org.example.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@Configuration +@EnableJpaAuditing +public class JpaAuditingConfig { +} diff --git a/module-common/src/main/java/org/example/config/RateLimiterConfig.java b/module-common/src/main/java/org/example/config/RateLimiterConfig.java new file mode 100644 index 000000000..41a2e7d36 --- /dev/null +++ b/module-common/src/main/java/org/example/config/RateLimiterConfig.java @@ -0,0 +1,13 @@ +package org.example.config; + +import com.google.common.util.concurrent.RateLimiter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RateLimiterConfig { + @Bean + public RateLimiter rateLimiter() { + return RateLimiter.create(2.0); + } +} diff --git a/module-common/src/main/java/org/example/config/RedissonConfig.java b/module-common/src/main/java/org/example/config/RedissonConfig.java new file mode 100644 index 000000000..7d400286c --- /dev/null +++ b/module-common/src/main/java/org/example/config/RedissonConfig.java @@ -0,0 +1,33 @@ +package org.example.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.codec.JsonJacksonCodec; +import org.redisson.config.Config; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RedissonConfig { + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); // LocalDate, LocalDateTime 지원 + objectMapper.findAndRegisterModules(); + objectMapper.disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + config.setCodec(new JsonJacksonCodec(objectMapper)); + + config.useSingleServer() + .setAddress("redis://localhost:6378") + .setConnectionPoolSize(64) + .setConnectionMinimumIdleSize(10); + + return Redisson.create(config); + } +} diff --git a/module-common/src/main/java/org/example/config/RedissonLockUtil.java b/module-common/src/main/java/org/example/config/RedissonLockUtil.java new file mode 100644 index 000000000..815944d72 --- /dev/null +++ b/module-common/src/main/java/org/example/config/RedissonLockUtil.java @@ -0,0 +1,47 @@ +package org.example.config; + +import lombok.AllArgsConstructor; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +@Component +@AllArgsConstructor +public class RedissonLockUtil { + private final RedissonClient redissonClient; + + /** + * 분산락을 적용하여 함수 실행 + * @param lockKeys 락 키 값 (예: "lock:seat:1:A:5") + * @param waitTime 락 대기 시간 (초) + * @param leaseTime 락 유지 시간 (초) + * @param task 락을 걸고 실행할 함수 + * @return task 실행 결과 + */ + public T executeWithMultiLock(List lockKeys, int waitTime, int leaseTime, Supplier task) { + List locks = lockKeys.stream() + .map(redissonClient::getLock) + .toList(); + try { + boolean locked = redissonClient.getMultiLock(locks.toArray(new RLock[0])) + .tryLock(waitTime, leaseTime, TimeUnit.SECONDS); + if (!locked) { + throw new RuntimeException("락 획득 실패: " + lockKeys); + } + return task.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("락 처리 중 인터럽트 발생", e); + } finally { + for (RLock lock : locks) { + if (lock.isLocked() && lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } + } +} diff --git a/module-common/src/main/java/org/example/entity/BaseEntity.java b/module-common/src/main/java/org/example/entity/BaseEntity.java new file mode 100644 index 000000000..cb955e78d --- /dev/null +++ b/module-common/src/main/java/org/example/entity/BaseEntity.java @@ -0,0 +1,31 @@ +package org.example.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedBy; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedBy; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@EntityListeners(AuditingEntityListener.class) +@MappedSuperclass +@Getter +public class BaseEntity { + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdDate; + + @LastModifiedDate + private LocalDateTime lastModifiedDate; + + @CreatedBy + private String updatedBy; + + @LastModifiedBy + private String lastModifiedBy; +} diff --git a/module-common/src/main/java/org/example/exception/BaseException.java b/module-common/src/main/java/org/example/exception/BaseException.java new file mode 100644 index 000000000..7f788ff56 --- /dev/null +++ b/module-common/src/main/java/org/example/exception/BaseException.java @@ -0,0 +1,14 @@ +package org.example.exception; + +import lombok.Getter; +import org.example.baseresponse.ResponseStatus; + +@Getter +public class BaseException extends RuntimeException { + private final ResponseStatus exceptionStatus; + + public BaseException(ResponseStatus exceptionStatus) { + super(exceptionStatus.getMessage()); + this.exceptionStatus = exceptionStatus; + } +} \ No newline at end of file diff --git a/module-common/src/main/java/org/example/exception/RateLimitExceededException.java b/module-common/src/main/java/org/example/exception/RateLimitExceededException.java new file mode 100644 index 000000000..ebddbaa52 --- /dev/null +++ b/module-common/src/main/java/org/example/exception/RateLimitExceededException.java @@ -0,0 +1,9 @@ +package org.example.exception; + +import org.example.baseresponse.ResponseStatus; + +public class RateLimitExceededException extends BaseException { + public RateLimitExceededException(ResponseStatus exceptionStatus) { + super(exceptionStatus); + } +} diff --git a/module-common/src/main/java/org/example/exception/SeatException.java b/module-common/src/main/java/org/example/exception/SeatException.java new file mode 100644 index 000000000..7fc42dc5b --- /dev/null +++ b/module-common/src/main/java/org/example/exception/SeatException.java @@ -0,0 +1,9 @@ +package org.example.exception; + +import org.example.baseresponse.ResponseStatus; + +public class SeatException extends BaseException { + public SeatException(ResponseStatus exceptionStatus) { + super(exceptionStatus); + } +} \ No newline at end of file diff --git a/module-common/src/main/java/org/example/exception/handler/GlobalExceptionHandler.java b/module-common/src/main/java/org/example/exception/handler/GlobalExceptionHandler.java new file mode 100644 index 000000000..5b4b943ac --- /dev/null +++ b/module-common/src/main/java/org/example/exception/handler/GlobalExceptionHandler.java @@ -0,0 +1,52 @@ +package org.example.exception.handler; + +import jakarta.validation.ConstraintViolationException; +import org.example.baseresponse.error.BaseErrorResponse; +import org.example.exception.RateLimitExceededException; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@RestControllerAdvice +public class GlobalExceptionHandler { + // @Valid 검증 실패 처리 + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationExceptions(MethodArgumentNotValidException ex) { + Map errors = new HashMap<>(); + ex.getBindingResult().getFieldErrors().forEach(error -> { + errors.put(error.getField(), error.getDefaultMessage()); + }); + + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(errors); + } + + // @Validated 검증 실패 처리 + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolation(ConstraintViolationException ex) { + // ConstraintViolation 메시지 수집 + List errors = ex.getConstraintViolations() + .stream() + .map(violation -> violation.getPropertyPath() + ": " + violation.getMessage()) + .collect(Collectors.toList()); + + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(errors); + } + + @ResponseStatus(HttpStatus.TOO_MANY_REQUESTS) + @ExceptionHandler(RateLimitExceededException.class) + public BaseErrorResponse handleRateLimitExceeded(RateLimitExceededException ex) { + return new BaseErrorResponse(ex.getExceptionStatus()); + } +} diff --git a/module-common/src/main/java/org/example/exception/handler/SeatExceptionControllerAdvice.java b/module-common/src/main/java/org/example/exception/handler/SeatExceptionControllerAdvice.java new file mode 100644 index 000000000..34168c89c --- /dev/null +++ b/module-common/src/main/java/org/example/exception/handler/SeatExceptionControllerAdvice.java @@ -0,0 +1,21 @@ +package org.example.exception.handler; + +import lombok.extern.slf4j.Slf4j; +import org.example.baseresponse.error.BaseErrorResponse; +import org.example.exception.SeatException; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class SeatExceptionControllerAdvice { + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler({SeatException.class}) + public BaseErrorResponse handle_BaseException(SeatException e) { + log.error("[handle_BadRequest]", e); + return new BaseErrorResponse(e.getExceptionStatus()); + } + +} diff --git a/module-domain/build.gradle b/module-domain/build.gradle new file mode 100644 index 000000000..d0d91e98b --- /dev/null +++ b/module-domain/build.gradle @@ -0,0 +1,32 @@ +plugins { + id 'java' + id 'jacoco' +} + +jar.enabled = true + +dependencies { + implementation project(':module-common') + + testImplementation platform('org.junit:junit-bom:5.9.1') + testImplementation 'org.junit.jupiter:junit-jupiter' +} + +jacoco { + toolVersion = "0.8.10" +} + +jacocoTestReport { + dependsOn test + + reports { + xml.required = false + csv.required = false + html.required = true + } +} + +test { + useJUnitPlatform() + finalizedBy jacocoTestReport +} \ No newline at end of file diff --git a/module-domain/src/main/generated/org/example/domain/movie/QMovie.java b/module-domain/src/main/generated/org/example/domain/movie/QMovie.java new file mode 100644 index 000000000..35d512433 --- /dev/null +++ b/module-domain/src/main/generated/org/example/domain/movie/QMovie.java @@ -0,0 +1,65 @@ +package org.example.domain.movie; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QMovie is a Querydsl query type for Movie + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QMovie extends EntityPathBase { + + private static final long serialVersionUID = -89414410L; + + public static final QMovie movie = new QMovie("movie"); + + public final org.example.entity.QBaseEntity _super = new org.example.entity.QBaseEntity(this); + + public final EnumPath ageRating = createEnum("ageRating", AgeRating.class); + + //inherited + public final DateTimePath createdDate = _super.createdDate; + + public final EnumPath genre = createEnum("genre", Genre.class); + + public final NumberPath id = createNumber("id", Long.class); + + public final BooleanPath isPlaying = createBoolean("isPlaying"); + + //inherited + public final StringPath lastModifiedBy = _super.lastModifiedBy; + + //inherited + public final DateTimePath lastModifiedDate = _super.lastModifiedDate; + + public final DatePath releaseDate = createDate("releaseDate", java.time.LocalDate.class); + + public final NumberPath runningTime = createNumber("runningTime", Integer.class); + + public final StringPath thumbnail = createString("thumbnail"); + + public final StringPath title = createString("title"); + + //inherited + public final StringPath updatedBy = _super.updatedBy; + + public QMovie(String variable) { + super(Movie.class, forVariable(variable)); + } + + public QMovie(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QMovie(PathMetadata metadata) { + super(Movie.class, metadata); + } + +} + diff --git a/module-domain/src/main/generated/org/example/domain/reservation/QReservation.java b/module-domain/src/main/generated/org/example/domain/reservation/QReservation.java new file mode 100644 index 000000000..c37e3793d --- /dev/null +++ b/module-domain/src/main/generated/org/example/domain/reservation/QReservation.java @@ -0,0 +1,57 @@ +package org.example.domain.reservation; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QReservation is a Querydsl query type for Reservation + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QReservation extends EntityPathBase { + + private static final long serialVersionUID = -682898258L; + + public static final QReservation reservation = new QReservation("reservation"); + + public final org.example.entity.QBaseEntity _super = new org.example.entity.QBaseEntity(this); + + //inherited + public final DateTimePath createdDate = _super.createdDate; + + public final NumberPath id = createNumber("id", Long.class); + + //inherited + public final StringPath lastModifiedBy = _super.lastModifiedBy; + + //inherited + public final DateTimePath lastModifiedDate = _super.lastModifiedDate; + + public final DateTimePath reserveTime = createDateTime("reserveTime", java.time.LocalDateTime.class); + + public final NumberPath screenScheduleId = createNumber("screenScheduleId", Long.class); + + //inherited + public final StringPath updatedBy = _super.updatedBy; + + public final NumberPath usersId = createNumber("usersId", Long.class); + + public QReservation(String variable) { + super(Reservation.class, forVariable(variable)); + } + + public QReservation(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QReservation(PathMetadata metadata) { + super(Reservation.class, metadata); + } + +} + diff --git a/module-domain/src/main/generated/org/example/domain/reservationseat/QReservationSeat.java b/module-domain/src/main/generated/org/example/domain/reservationseat/QReservationSeat.java new file mode 100644 index 000000000..2507f04ad --- /dev/null +++ b/module-domain/src/main/generated/org/example/domain/reservationseat/QReservationSeat.java @@ -0,0 +1,55 @@ +package org.example.domain.reservationseat; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QReservationSeat is a Querydsl query type for ReservationSeat + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QReservationSeat extends EntityPathBase { + + private static final long serialVersionUID = 652325848L; + + public static final QReservationSeat reservationSeat = new QReservationSeat("reservationSeat"); + + public final org.example.entity.QBaseEntity _super = new org.example.entity.QBaseEntity(this); + + //inherited + public final DateTimePath createdDate = _super.createdDate; + + public final NumberPath id = createNumber("id", Long.class); + + //inherited + public final StringPath lastModifiedBy = _super.lastModifiedBy; + + //inherited + public final DateTimePath lastModifiedDate = _super.lastModifiedDate; + + public final NumberPath reservationId = createNumber("reservationId", Long.class); + + public final NumberPath seatId = createNumber("seatId", Long.class); + + //inherited + public final StringPath updatedBy = _super.updatedBy; + + public QReservationSeat(String variable) { + super(ReservationSeat.class, forVariable(variable)); + } + + public QReservationSeat(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QReservationSeat(PathMetadata metadata) { + super(ReservationSeat.class, metadata); + } + +} + diff --git a/module-domain/src/main/generated/org/example/domain/screenroom/QScreenRoom.java b/module-domain/src/main/generated/org/example/domain/screenroom/QScreenRoom.java new file mode 100644 index 000000000..c3643054d --- /dev/null +++ b/module-domain/src/main/generated/org/example/domain/screenroom/QScreenRoom.java @@ -0,0 +1,53 @@ +package org.example.domain.screenroom; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QScreenRoom is a Querydsl query type for ScreenRoom + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QScreenRoom extends EntityPathBase { + + private static final long serialVersionUID = -110207566L; + + public static final QScreenRoom screenRoom = new QScreenRoom("screenRoom"); + + public final org.example.entity.QBaseEntity _super = new org.example.entity.QBaseEntity(this); + + //inherited + public final DateTimePath createdDate = _super.createdDate; + + public final NumberPath id = createNumber("id", Long.class); + + //inherited + public final StringPath lastModifiedBy = _super.lastModifiedBy; + + //inherited + public final DateTimePath lastModifiedDate = _super.lastModifiedDate; + + public final StringPath name = createString("name"); + + //inherited + public final StringPath updatedBy = _super.updatedBy; + + public QScreenRoom(String variable) { + super(ScreenRoom.class, forVariable(variable)); + } + + public QScreenRoom(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QScreenRoom(PathMetadata metadata) { + super(ScreenRoom.class, metadata); + } + +} + diff --git a/module-domain/src/main/generated/org/example/domain/screenschedule/QScreenSchedule.java b/module-domain/src/main/generated/org/example/domain/screenschedule/QScreenSchedule.java new file mode 100644 index 000000000..368e34dff --- /dev/null +++ b/module-domain/src/main/generated/org/example/domain/screenschedule/QScreenSchedule.java @@ -0,0 +1,59 @@ +package org.example.domain.screenschedule; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QScreenSchedule is a Querydsl query type for ScreenSchedule + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QScreenSchedule extends EntityPathBase { + + private static final long serialVersionUID = 2121698994L; + + public static final QScreenSchedule screenSchedule = new QScreenSchedule("screenSchedule"); + + public final org.example.entity.QBaseEntity _super = new org.example.entity.QBaseEntity(this); + + //inherited + public final DateTimePath createdDate = _super.createdDate; + + public final DateTimePath endTime = createDateTime("endTime", java.time.LocalDateTime.class); + + public final NumberPath id = createNumber("id", Long.class); + + //inherited + public final StringPath lastModifiedBy = _super.lastModifiedBy; + + //inherited + public final DateTimePath lastModifiedDate = _super.lastModifiedDate; + + public final NumberPath movieId = createNumber("movieId", Long.class); + + public final NumberPath screenRoomId = createNumber("screenRoomId", Long.class); + + public final DateTimePath startTime = createDateTime("startTime", java.time.LocalDateTime.class); + + //inherited + public final StringPath updatedBy = _super.updatedBy; + + public QScreenSchedule(String variable) { + super(ScreenSchedule.class, forVariable(variable)); + } + + public QScreenSchedule(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QScreenSchedule(PathMetadata metadata) { + super(ScreenSchedule.class, metadata); + } + +} + diff --git a/module-domain/src/main/generated/org/example/domain/seat/QSeat.java b/module-domain/src/main/generated/org/example/domain/seat/QSeat.java new file mode 100644 index 000000000..0bd2b88b4 --- /dev/null +++ b/module-domain/src/main/generated/org/example/domain/seat/QSeat.java @@ -0,0 +1,57 @@ +package org.example.domain.seat; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QSeat is a Querydsl query type for Seat + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QSeat extends EntityPathBase { + + private static final long serialVersionUID = 809447314L; + + public static final QSeat seat = new QSeat("seat"); + + public final org.example.entity.QBaseEntity _super = new org.example.entity.QBaseEntity(this); + + public final EnumPath col = createEnum("col", Col.class); + + //inherited + public final DateTimePath createdDate = _super.createdDate; + + public final NumberPath id = createNumber("id", Long.class); + + //inherited + public final StringPath lastModifiedBy = _super.lastModifiedBy; + + //inherited + public final DateTimePath lastModifiedDate = _super.lastModifiedDate; + + public final EnumPath row = createEnum("row", Row.class); + + public final NumberPath screenRoomId = createNumber("screenRoomId", Long.class); + + //inherited + public final StringPath updatedBy = _super.updatedBy; + + public QSeat(String variable) { + super(Seat.class, forVariable(variable)); + } + + public QSeat(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QSeat(PathMetadata metadata) { + super(Seat.class, metadata); + } + +} + diff --git a/module-domain/src/main/generated/org/example/domain/users/QUsers.java b/module-domain/src/main/generated/org/example/domain/users/QUsers.java new file mode 100644 index 000000000..cd4a210be --- /dev/null +++ b/module-domain/src/main/generated/org/example/domain/users/QUsers.java @@ -0,0 +1,53 @@ +package org.example.domain.users; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QUsers is a Querydsl query type for Users + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QUsers extends EntityPathBase { + + private static final long serialVersionUID = 643809446L; + + public static final QUsers users = new QUsers("users"); + + public final org.example.entity.QBaseEntity _super = new org.example.entity.QBaseEntity(this); + + //inherited + public final DateTimePath createdDate = _super.createdDate; + + public final NumberPath id = createNumber("id", Long.class); + + //inherited + public final StringPath lastModifiedBy = _super.lastModifiedBy; + + //inherited + public final DateTimePath lastModifiedDate = _super.lastModifiedDate; + + public final StringPath name = createString("name"); + + //inherited + public final StringPath updatedBy = _super.updatedBy; + + public QUsers(String variable) { + super(Users.class, forVariable(variable)); + } + + public QUsers(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QUsers(PathMetadata metadata) { + super(Users.class, metadata); + } + +} + diff --git a/module-domain/src/main/java/org/example/DomainApplication.java b/module-domain/src/main/java/org/example/DomainApplication.java new file mode 100644 index 000000000..005b5efef --- /dev/null +++ b/module-domain/src/main/java/org/example/DomainApplication.java @@ -0,0 +1,10 @@ +package org.example; + +import org.springframework.boot.SpringApplication; + +public class DomainApplication { + + public static void main(String[] args) { + SpringApplication.run(DomainApplication.class, args); + } +} diff --git a/module-domain/src/main/java/org/example/domain/movie/AgeRating.java b/module-domain/src/main/java/org/example/domain/movie/AgeRating.java new file mode 100644 index 000000000..1fde9badf --- /dev/null +++ b/module-domain/src/main/java/org/example/domain/movie/AgeRating.java @@ -0,0 +1,8 @@ +package org.example.domain.movie; + +public enum AgeRating { + PG, // 전체관람가 + PG_13, // 12세 이상 관람가 + R, // 15세 이상 관람가 + NC_17 // 청소년 관람 불가 +} diff --git a/module-domain/src/main/java/org/example/domain/movie/Genre.java b/module-domain/src/main/java/org/example/domain/movie/Genre.java new file mode 100644 index 000000000..504aba5e7 --- /dev/null +++ b/module-domain/src/main/java/org/example/domain/movie/Genre.java @@ -0,0 +1,8 @@ +package org.example.domain.movie; + +public enum Genre { + ACTION, + ROMANCE, + HORROR, + SF +} diff --git a/module-domain/src/main/java/org/example/domain/movie/Movie.java b/module-domain/src/main/java/org/example/domain/movie/Movie.java new file mode 100644 index 000000000..029b4f807 --- /dev/null +++ b/module-domain/src/main/java/org/example/domain/movie/Movie.java @@ -0,0 +1,46 @@ +package org.example.domain.movie; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.example.entity.BaseEntity; + +import java.time.LocalDate; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "Movie", indexes = { + @Index(name = "genre_title_idx", columnList = "genre, title"), + @Index(name = "title_idx", columnList = "title"), +}) +public class Movie extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "movie_id", nullable = false) + private Long id; + + @Column(length = 100, nullable = false) + private String title; + + @Column(length = 500, nullable = false) + private String thumbnail; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private Genre genre; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private AgeRating ageRating; + + @Column(nullable = false) + private LocalDate releaseDate; + + @Column(nullable = false) + private int runningTime; + + @Column(nullable = false) + private boolean isPlaying; +} diff --git a/module-domain/src/main/java/org/example/domain/reservation/Reservation.java b/module-domain/src/main/java/org/example/domain/reservation/Reservation.java new file mode 100644 index 000000000..243e7c911 --- /dev/null +++ b/module-domain/src/main/java/org/example/domain/reservation/Reservation.java @@ -0,0 +1,44 @@ +package org.example.domain.reservation; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.example.entity.BaseEntity; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Reservation extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "reservation_id", nullable = false) + private Long id; + + @CreationTimestamp + @Column(nullable = false) + private LocalDateTime reserveTime; + + @Column(nullable = false) + private Long usersId; + + @Column(nullable = false) + private Long screenScheduleId; + + @Builder + public Reservation(Long usersId, Long screenScheduleId) { + this.usersId = usersId; + this.screenScheduleId = screenScheduleId; + } + + public static Reservation of(Long usersId, Long screenScheduleId) { + return Reservation.builder() + .usersId(usersId) + .screenScheduleId(screenScheduleId) + .build(); + } +} diff --git a/module-domain/src/main/java/org/example/domain/reservationseat/ReservationSeat.java b/module-domain/src/main/java/org/example/domain/reservationseat/ReservationSeat.java new file mode 100644 index 000000000..40a943c03 --- /dev/null +++ b/module-domain/src/main/java/org/example/domain/reservationseat/ReservationSeat.java @@ -0,0 +1,83 @@ +package org.example.domain.reservationseat; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.example.domain.reservation.Reservation; +import org.example.domain.seat.Col; +import org.example.domain.seat.Row; +import org.example.dto.SeatsDto; +import org.example.entity.BaseEntity; +import org.example.exception.SeatException; + +import java.util.List; + +import static org.example.baseresponse.BaseResponseStatus.*; +import static org.example.baseresponse.BaseResponseStatus.SEAT_COLUMN_DISCONTINUITY_ERROR; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ReservationSeat extends BaseEntity { + private static final int MAX_SEAT_COUNT = 5; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "reservation_seat_id", nullable = false) + private Long id; + + @Column(nullable = false) + private Long reservationId; + + @Column(nullable = false) + private Long seatId; + + @Builder + public ReservationSeat(Long reservationId, Long seatId) { + this.reservationId = reservationId; + this.seatId = seatId; + } + + public static ReservationSeat of(Long reservationId, Long seatId) { + return ReservationSeat.builder() + .reservationId(reservationId) + .seatId(seatId) + .build(); + } + + public static void validateCountExceeded(List reservationSeats, List seatsDtoByUserId) { + if (reservationSeats.size()+seatsDtoByUserId.size() > MAX_SEAT_COUNT) { + throw new SeatException(MAX_SEATS_EXCEEDED_ERROR); + } + } + + public static void containsReservedSeat(List reservationSeats, List seatsDtoByUserId) { + for (SeatsDto seatsDto : seatsDtoByUserId) { + for (SeatsDto reservationSeat : reservationSeats) { + if (seatsDto.getRow().equals(reservationSeat.getRow()) + && seatsDto.getCol().equals(reservationSeat.getCol())) { + throw new SeatException(ALREADY_RESERVED_SEAT_ERROR); + } + } + } + } + + public static void isSameRow(List reservationSeats, List seatsDtoByUserId) { + Row row = seatsDtoByUserId.get(0).getRow(); + for (SeatsDto reservationSeat : reservationSeats) { + if (!row.equals(reservationSeat.getRow())) { + throw new SeatException(SEAT_ROW_DISCONTINUITY_ERROR); + } + } + } + + public static void isContinuousCol(List reservationSeats, List seatsDtoByUserId) { + Col reservedCol = seatsDtoByUserId.get(seatsDtoByUserId.size() - 1).getCol(); + Col reservationCol = reservationSeats.get(0).getCol(); + if (reservationCol.getColumn() != reservedCol.getColumn()+1) { + throw new SeatException(SEAT_COLUMN_DISCONTINUITY_ERROR); + } + } +} diff --git a/module-domain/src/main/java/org/example/domain/screenroom/ScreenRoom.java b/module-domain/src/main/java/org/example/domain/screenroom/ScreenRoom.java new file mode 100644 index 000000000..512f04269 --- /dev/null +++ b/module-domain/src/main/java/org/example/domain/screenroom/ScreenRoom.java @@ -0,0 +1,18 @@ +package org.example.domain.screenroom; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.example.entity.BaseEntity; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ScreenRoom extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "screen_room_id", nullable = false) + private Long id; + + @Column(nullable = false) + private String name; +} diff --git a/module-domain/src/main/java/org/example/domain/screenschedule/ScreenSchedule.java b/module-domain/src/main/java/org/example/domain/screenschedule/ScreenSchedule.java new file mode 100644 index 000000000..c5ae9bf2e --- /dev/null +++ b/module-domain/src/main/java/org/example/domain/screenschedule/ScreenSchedule.java @@ -0,0 +1,33 @@ +package org.example.domain.screenschedule; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.example.entity.BaseEntity; + +import java.time.LocalDateTime; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "ScreenSchedule", indexes = { + @Index(name = "movieId_idx", columnList = "movieId"), + @Index(name = "screenRoomId_idx", columnList = "screenRoomId"), +}) +public class ScreenSchedule extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "screen_schedule_id", nullable = false) + private Long id; + + @Column(nullable = false) + private LocalDateTime startTime; + + @Column(nullable = false) + private LocalDateTime endTime; + + @Column(nullable = false) + private Long movieId; + + @Column(nullable = false) + private Long screenRoomId; +} diff --git a/module-domain/src/main/java/org/example/domain/seat/Col.java b/module-domain/src/main/java/org/example/domain/seat/Col.java new file mode 100644 index 000000000..c4718425b --- /dev/null +++ b/module-domain/src/main/java/org/example/domain/seat/Col.java @@ -0,0 +1,19 @@ +package org.example.domain.seat; + +public enum Col { + COL_1(1), + COL_2(2), + COL_3(3), + COL_4(4), + COL_5(5); + + private final int column; + + Col(int column) { + this.column = column; + } + + public int getColumn() { + return column; + } +} diff --git a/module-domain/src/main/java/org/example/domain/seat/Row.java b/module-domain/src/main/java/org/example/domain/seat/Row.java new file mode 100644 index 000000000..e29ebb309 --- /dev/null +++ b/module-domain/src/main/java/org/example/domain/seat/Row.java @@ -0,0 +1,22 @@ +package org.example.domain.seat; + +import lombok.Getter; + +@Getter +public enum Row { + ROW_A("A"), + ROW_B("B"), + ROW_C("C"), + ROW_D("D"), + ROW_E("E"); + + private final String row; + + Row(String row) { + this.row = row; + } + + public String getRow() { + return row; + } +} diff --git a/module-domain/src/main/java/org/example/domain/seat/Seat.java b/module-domain/src/main/java/org/example/domain/seat/Seat.java new file mode 100644 index 000000000..6984c535d --- /dev/null +++ b/module-domain/src/main/java/org/example/domain/seat/Seat.java @@ -0,0 +1,82 @@ +package org.example.domain.seat; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.dto.SeatsDto; +import org.example.entity.BaseEntity; +import org.example.exception.SeatException; + +import java.util.Comparator; +import java.util.List; + +import static org.example.baseresponse.BaseResponseStatus.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Seat extends BaseEntity { + private static final int MIN_SEAT_COUNT = 1; + private static final int MAX_SEAT_COUNT = 5; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "seat_id", nullable = false) + private Long id; + + @Column(nullable = false, name = "seat_row") + @Enumerated(EnumType.STRING) + private Row row; + + @Column(nullable = false, name = "seat_col") + @Enumerated(EnumType.STRING) + private Col col; + + @Column(nullable = false) + private Long screenRoomId; + + @Builder + public Seat(Long id, Row row, Col col, Long screenRoomId) { + this.id = id; + this.row = row; + this.col = col; + this.screenRoomId = screenRoomId; + } + + public static Seat of(Long id, Row row, Col col, Long screenRoomId) { + return Seat.builder() + .id(id) + .row(row) + .col(col) + .screenRoomId(screenRoomId) + .build(); + } + + public static void validateSeatCount(int seatSize) { + if (seatSize > MAX_SEAT_COUNT || seatSize < MIN_SEAT_COUNT) { + throw new SeatException(UNAVAILABLE_SEAT_COUNT_ERROR); + } + } + + public static void validateContinuousSeats(List seats) { + seats.sort(Comparator.comparing(seat -> seat.getCol().getColumn())); + + for (int i = 1; i < seats.size(); i++) { + SeatsDto prev = seats.get(i - 1); + SeatsDto current = seats.get(i); + + if (!prev.getRow().getRow().equals(current.getRow().getRow())) { + throw new SeatException(SEAT_ROW_DISCONTINUITY_ERROR); + } + + int prevCol = prev.getCol().getColumn(); + int currentCol = current.getCol().getColumn(); + if (currentCol != prevCol + 1) { + throw new SeatException(SEAT_COLUMN_DISCONTINUITY_ERROR); + } + } + } +} diff --git a/module-domain/src/main/java/org/example/domain/users/Users.java b/module-domain/src/main/java/org/example/domain/users/Users.java new file mode 100644 index 000000000..c653f6df6 --- /dev/null +++ b/module-domain/src/main/java/org/example/domain/users/Users.java @@ -0,0 +1,18 @@ +package org.example.domain.users; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.example.entity.BaseEntity; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Users extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "users_id", nullable = false) + private Long id; + + @Column(nullable = false) + private String name; +} diff --git a/module-domain/src/main/java/org/example/dto/MovieScreeningInfo.java b/module-domain/src/main/java/org/example/dto/MovieScreeningInfo.java new file mode 100644 index 000000000..585ab0cf3 --- /dev/null +++ b/module-domain/src/main/java/org/example/dto/MovieScreeningInfo.java @@ -0,0 +1,26 @@ +package org.example.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.example.domain.movie.AgeRating; +import org.example.domain.movie.Genre; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class MovieScreeningInfo { + private Long movieId; + private String title; + private String thumbnail; + private Genre genre; + private AgeRating ageRating; + private LocalDate releaseDate; + private int runningTime; + private String screenRoomName; + private LocalDateTime startTime; + private LocalDateTime endTIme; +} diff --git a/module-domain/src/main/java/org/example/dto/SeatsDto.java b/module-domain/src/main/java/org/example/dto/SeatsDto.java new file mode 100644 index 000000000..78ea46f2f --- /dev/null +++ b/module-domain/src/main/java/org/example/dto/SeatsDto.java @@ -0,0 +1,13 @@ +package org.example.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.example.domain.seat.Col; +import org.example.domain.seat.Row; + +@Getter +@AllArgsConstructor +public class SeatsDto { + private Row row; + private Col col; +} diff --git a/module-domain/src/main/java/org/example/repository/MovieJpaRepository.java b/module-domain/src/main/java/org/example/repository/MovieJpaRepository.java new file mode 100644 index 000000000..c3eeab800 --- /dev/null +++ b/module-domain/src/main/java/org/example/repository/MovieJpaRepository.java @@ -0,0 +1,22 @@ +package org.example.repository; + +import org.example.domain.movie.Genre; +import org.example.domain.movie.Movie; +import org.example.dto.MovieScreeningInfo; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface MovieJpaRepository extends JpaRepository { + @Query("select new org.example.dto.MovieScreeningInfo" + + "(m.id, m.title, m.thumbnail, m.genre, m.ageRating, m.releaseDate, m.runningTime, sr.name, ss.startTime, ss.endTime) " + + "from Movie m " + + "join ScreenSchedule ss on m.id = ss.movieId " + + "join ScreenRoom sr on ss.screenRoomId = sr.id " + + "where (:title IS NULL OR m.title LIKE %:title%) " + + "AND (:genre IS NULL OR m.genre = :genre) " + + "And (m.isPlaying = true)") + List findScreeningInfos(@Param("title") String title, @Param("genre") Genre genre); +} \ No newline at end of file diff --git a/module-domain/src/main/java/org/example/repository/ReservationJpaRepository.java b/module-domain/src/main/java/org/example/repository/ReservationJpaRepository.java new file mode 100644 index 000000000..a029aa0ad --- /dev/null +++ b/module-domain/src/main/java/org/example/repository/ReservationJpaRepository.java @@ -0,0 +1,7 @@ +package org.example.repository; + +import org.example.domain.reservation.Reservation; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReservationJpaRepository extends JpaRepository { +} diff --git a/module-domain/src/main/java/org/example/repository/ReservationSeatRepository.java b/module-domain/src/main/java/org/example/repository/ReservationSeatRepository.java new file mode 100644 index 000000000..b5e616e5d --- /dev/null +++ b/module-domain/src/main/java/org/example/repository/ReservationSeatRepository.java @@ -0,0 +1,33 @@ +package org.example.repository; + +import jakarta.persistence.LockModeType; +import org.example.domain.reservationseat.ReservationSeat; +import org.example.dto.SeatsDto; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface ReservationSeatRepository extends JpaRepository { + @Query("select new org.example.dto.SeatsDto(s.row, s.col) " + + "from ReservationSeat rs " + + "join Seat s on rs.seatId=s.id " + + "join Reservation r on rs.reservationId = r.id " + + "where r.usersId=:userId and r.screenScheduleId=:screenScheduleId") + List findReservedSeatByUserIdAndScreenScheduleId(@Param("userId") Long userId, @Param("screenScheduleId") Long screenScheduleId); + + @Query("select rs.id " + + "from ReservationSeat rs " + + "join Seat s on rs.seatId=s.id " + + "join Reservation r on rs.reservationId = r.id " + + "where r.screenScheduleId=:screenScheduleId") + List findReservedSeatByScreenScheduleId(@Param("screenScheduleId") Long screenScheduleId); + +// @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select rs from ReservationSeat rs join Reservation r on rs.reservationId=r.id " + + "where r.screenScheduleId=:screenScheduleId and rs.seatId = :seatId") + Optional findReservedSeatBySeatId(@Param("screenScheduleId") Long screenScheduleId, @Param("seatId") Long seatId); +} diff --git a/module-domain/src/main/java/org/example/repository/ScreenScheduleJpaRepository.java b/module-domain/src/main/java/org/example/repository/ScreenScheduleJpaRepository.java new file mode 100644 index 000000000..5449f6ce9 --- /dev/null +++ b/module-domain/src/main/java/org/example/repository/ScreenScheduleJpaRepository.java @@ -0,0 +1,11 @@ +package org.example.repository; + +import org.example.domain.screenschedule.ScreenSchedule; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ScreenScheduleJpaRepository extends JpaRepository { + @Query("select ss.screenRoomId from ScreenSchedule ss where ss.id = :id") + Long findScreenRoomIdById(@Param("id") Long id); +} diff --git a/module-domain/src/main/java/org/example/repository/SeatJpaRepository.java b/module-domain/src/main/java/org/example/repository/SeatJpaRepository.java new file mode 100644 index 000000000..9425c258f --- /dev/null +++ b/module-domain/src/main/java/org/example/repository/SeatJpaRepository.java @@ -0,0 +1,17 @@ +package org.example.repository; + +import org.example.domain.seat.Col; +import org.example.domain.seat.Row; +import org.example.domain.seat.Seat; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface SeatJpaRepository extends JpaRepository { + @Query("select s from Seat s where s.screenRoomId=:screenRoomId and s.row=:row and s.col=:col") + Optional findSeats(@Param("screenRoomId")Long screenRoomId, + @Param("row") Row row, + @Param("col") Col col); +} diff --git a/module-domain/src/test/java/org/example/domain/reservationseat/ReservationSeatTest.java b/module-domain/src/test/java/org/example/domain/reservationseat/ReservationSeatTest.java new file mode 100644 index 000000000..a35786aca --- /dev/null +++ b/module-domain/src/test/java/org/example/domain/reservationseat/ReservationSeatTest.java @@ -0,0 +1,112 @@ +package org.example.domain.reservationseat; + +import org.assertj.core.api.Assertions; +import org.example.domain.seat.Col; +import org.example.domain.seat.Row; +import org.example.dto.SeatsDto; +import org.example.exception.SeatException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +class ReservationSeatTest { + + @Test + @DisplayName("이미 예매한 좌석과 예매하려는 좌석의 합이 5개가 넘으면 예외가 발생한다.") + void exceedTotalReservationSeat_ThrowException() { + // given + List reservationSeats = new ArrayList<>(); + reservationSeats.add(new SeatsDto(Row.ROW_A, Col.COL_1)); + reservationSeats.add(new SeatsDto(Row.ROW_A, Col.COL_2)); + reservationSeats.add(new SeatsDto(Row.ROW_A, Col.COL_3)); + + List reservedSeats = new ArrayList<>(); + reservedSeats.add(new SeatsDto(Row.ROW_A, Col.COL_4)); + reservedSeats.add(new SeatsDto(Row.ROW_A, Col.COL_5)); + reservedSeats.add(new SeatsDto(Row.ROW_B, Col.COL_1)); + + // when + Throwable thrown = assertThrows(SeatException.class, () -> + ReservationSeat.validateCountExceeded(reservationSeats, reservedSeats) + ); + + // then + Assertions.assertThat(thrown) + .isInstanceOf(SeatException.class) + .hasMessage("최대 예약 가능한 좌석을 초과했습니다."); + } + + @Test + @DisplayName("이미 예매한 좌석을 같은 사용자가 또 예매하려고 하면 예외가 발생한다.") + void containsReservedSeat_ThrowException() { + // given + List reservationSeats = new ArrayList<>(); + reservationSeats.add(new SeatsDto(Row.ROW_A, Col.COL_1)); + reservationSeats.add(new SeatsDto(Row.ROW_A, Col.COL_2)); + + List reservedSeats = new ArrayList<>(); + reservedSeats.add(new SeatsDto(Row.ROW_A, Col.COL_2)); + reservedSeats.add(new SeatsDto(Row.ROW_A, Col.COL_3)); + + // when + Throwable thrown = assertThrows(SeatException.class, () -> + ReservationSeat.containsReservedSeat(reservationSeats, reservedSeats) + ); + + // then + Assertions.assertThat(thrown) + .isInstanceOf(SeatException.class) + .hasMessage("이미 예약된 좌석입니다."); + } + + @Test + @DisplayName("같은 사용자가 이미 예매한 좌석들과 다른 행을 예매하려고 하면 예외가 발생한다.") + void notSameRow_ThrowException() { + // given + List reservationSeats = new ArrayList<>(); + reservationSeats.add(new SeatsDto(Row.ROW_A, Col.COL_1)); + reservationSeats.add(new SeatsDto(Row.ROW_A, Col.COL_2)); + + List reservedSeats = new ArrayList<>(); + reservedSeats.add(new SeatsDto(Row.ROW_B, Col.COL_3)); + reservedSeats.add(new SeatsDto(Row.ROW_B, Col.COL_4)); + + // when + Throwable thrown = assertThrows(SeatException.class, () -> + ReservationSeat.isSameRow(reservationSeats, reservedSeats) + ); + + // then + Assertions.assertThat(thrown) + .isInstanceOf(SeatException.class) + .hasMessage("연속된 좌석만 예약할 수 있습니다. 행이 다릅니다."); + } + + @Test + @DisplayName("같은 사용자가 이미 예매한 좌석들과 연속적이지 않은 열을 예매하려고 하면 예외가 발생한다.") + void notContinuousCol_ThrowException() { + // given + List reservationSeats = new ArrayList<>(); + reservationSeats.add(new SeatsDto(Row.ROW_A, Col.COL_1)); + reservationSeats.add(new SeatsDto(Row.ROW_A, Col.COL_2)); + + List reservedSeats = new ArrayList<>(); + reservedSeats.add(new SeatsDto(Row.ROW_A, Col.COL_4)); + reservedSeats.add(new SeatsDto(Row.ROW_A, Col.COL_5)); + + // when + Throwable thrown = assertThrows(SeatException.class, () -> + ReservationSeat.isContinuousCol(reservationSeats, reservedSeats) + ); + + // then + Assertions.assertThat(thrown) + .isInstanceOf(SeatException.class) + .hasMessage("연속된 좌석만 예약할 수 있습니다. 열이 연속되지 않았습니다."); + } + +} \ No newline at end of file diff --git a/module-domain/src/test/java/org/example/domain/seat/SeatTest.java b/module-domain/src/test/java/org/example/domain/seat/SeatTest.java new file mode 100644 index 000000000..e2ce1d8bd --- /dev/null +++ b/module-domain/src/test/java/org/example/domain/seat/SeatTest.java @@ -0,0 +1,86 @@ +package org.example.domain.seat; + +import org.assertj.core.api.Assertions; +import org.example.dto.SeatsDto; +import org.example.exception.SeatException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class SeatTest { + @Test + @DisplayName("예매하려는 좌석이 5개가 넘을 때 예외가 발생한다.") + void exceedCount_6Seat_ThrowException() { + // given + int seatSize = 6; + + // when + Throwable thrown = assertThrows(SeatException.class, () -> + Seat.validateSeatCount(seatSize) + ); + + // then + Assertions.assertThat(thrown) + .isInstanceOf(SeatException.class) + .hasMessage("예약 가능한 좌석 개수가 아닙니다."); + } + + @Test + @DisplayName("예매하려는 좌석이 0개일 때 예외가 발생한다.") + void exceedCount_0Seat_ThrowException() { + // given + int seatSize = 0; + + // when + Throwable thrown = assertThrows(SeatException.class, () -> + Seat.validateSeatCount(seatSize) + ); + + // then + Assertions.assertThat(thrown) + .isInstanceOf(SeatException.class) + .hasMessage("예약 가능한 좌석 개수가 아닙니다."); + } + + @Test + @DisplayName("예매하려는 좌석들의 행이 다르면 예외가 발생한다.") + void continuousSeats_NotSameRow_ThrowException() { + // given + List seats = new ArrayList<>(); + seats.add(new SeatsDto(Row.ROW_A, Col.COL_1)); + seats.add(new SeatsDto(Row.ROW_B, Col.COL_1)); + + // when + Throwable thrown = assertThrows(SeatException.class, () -> + Seat.validateContinuousSeats(seats) + ); + + // then + Assertions.assertThat(thrown) + .isInstanceOf(SeatException.class) + .hasMessage("연속된 좌석만 예약할 수 있습니다. 행이 다릅니다."); + } + + @Test + @DisplayName("예매하려는 좌석들이 연속적이지 않으면 다르면 예외가 발생한다.") + void continuousSeats_NotContinuousCol_ThrowException() { + // given + List seats = new ArrayList<>(); + seats.add(new SeatsDto(Row.ROW_A, Col.COL_1)); + seats.add(new SeatsDto(Row.ROW_A, Col.COL_3)); + + // when + Throwable thrown = assertThrows(SeatException.class, () -> + Seat.validateContinuousSeats(seats) + ); + + // then + Assertions.assertThat(thrown) + .isInstanceOf(SeatException.class) + .hasMessage("연속된 좌석만 예약할 수 있습니다. 열이 연속되지 않았습니다."); + } +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..d1ee2174b --- /dev/null +++ b/settings.gradle @@ -0,0 +1,5 @@ +rootProject.name = 'redis_1st' +include 'module-api' +include 'module-domain' +include 'module-common' +