diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..f91f64602 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,12 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + +# Binary files should be left untouched +*.jar binary + diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..228c3ef66 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +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/ + +docker/db/ +docker/influxdb/ +docker/grafana/ +node_modules/ +src/main/generated/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..2630e62d8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM golang:1.22-alpine3.20 as builder +WORKDIR $GOPATH/src/go.k6.io/k6 +ADD docker . +RUN apk --no-cache add git +RUN go install go.k6.io/xk6/cmd/xk6@latest +RUN xk6 build --with github.com/grafana/xk6-output-influxdb --output /tmp/k6 + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates && \ + adduser -D -u 12345 -g 12345 k6 +COPY --from=builder /tmp/k6 /usr/bin/k6 + +USER 12345 +WORKDIR /home/k6 +ENTRYPOINT ["k6"] \ No newline at end of file diff --git a/README.md b/README.md index 5fcc66b4d..9c140d2e5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,47 @@ -## [본 과정] 이커머스 핵심 프로세스 구현 -[단기 스킬업 Redis 교육 과정](https://hh-skillup.oopy.io/) 을 통해 상품 조회 및 주문 과정을 구현하며 현업에서 발생하는 문제를 Redis의 핵심 기술을 통해 해결합니다. -> Indexing, Caching을 통한 성능 개선 / 단계별 락 구현을 통한 동시성 이슈 해결 (낙관적/비관적 락, 분산락 등) +## Multi Module Design + +#### **의존관계** +- `presentation` → `application` → `infrastructure` +- `application` → `domain` +- `infrastructure` → `domain` + +#### **모듈별 역할** +- 각 모듈의 상세 설명은 해당 모듈 디렉토리 내 README.md 파일을 참고하십시오. + +--- + +## Table Design +- 테이블 구조는 아래의 다이어그램으로 대체됩니다. +- **![img.png](img.png)** + +--- + +## Architecture + +- **Port-Adapter 패턴 적용** + - 각 모듈 간 결합을 제거하여 독립성을 유지합니다. + - `Application` 계층은 비즈니스 로직만 담당하며, 외부 기술에 의존하지 않습니다. + - `Infrastructure` 계층은 외부 기술(JPA, DB, API 등)을 담당하며, Port를 통해 `Application`과 통신합니다. + +--- + +## API Design + +#### **`GET /api/v1/screenings`** +- 최신 영화별로 그룹핑하여 빠른 시간순으로 정렬된 상영 영화 목록을 반환합니다. +- 기본적으로 오늘부터 2일 이내 상영 영화 목록을 반환하며, 클라이언트 요청에 따라 기간을 조정할 수 있습니다. +- **HTTP 요청 예시**: + - IntelliJ Http Client `http/getScreenings.http` 참고. + +--- + +## 프로젝트 주요 특징 +- **모듈화된 설계**: 명확한 책임 분리를 통해 유지보수와 확장성을 높임. +- **API 유연성**: 다양한 클라이언트 요청 시나리오를 지원할 수 있는 유연한 파라미터 설계. +- **테이블 설계와 아키텍처**: 프로젝트 구조와 데이터베이스 설계를 통해 높은 일관성과 성능을 유지. + +--- + +## 데이터 관리 +- **flyway**로 형상 관리. Movie, Genre, Seat, Cinema 엔티티 생성 및 기본 데이터 생성 +- Screening(상영 영화 시간표)는 ``CommandLineRunner``를 구현하여 프로잭트 구동 시 생성 diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000..d3cc28acd --- /dev/null +++ b/build.gradle @@ -0,0 +1,68 @@ +plugins { + id 'java' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'project.redis' +version = '0.0.1-SNAPSHOT' + + +repositories { + mavenCentral() +} + +subprojects { + apply plugin: 'java' + apply plugin: 'java-library' + apply plugin: 'io.spring.dependency-management' + + java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } + + dependencyManagement { + imports { + mavenBom 'org.springframework.boot:spring-boot-dependencies:3.4.1' + } + } + + configurations { + compileOnly { + extendsFrom annotationProcessor + } + } + + repositories { + mavenCentral() + } + + + dependencies { + compileOnly 'org.projectlombok:lombok:1.18.36' + annotationProcessor 'org.projectlombok:lombok:1.18.36' + implementation 'com.fasterxml.jackson.core:jackson-databind' + implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' // Java Time 지원 + + + testImplementation 'com.fasterxml.jackson.core:jackson-databind' + testCompileOnly 'org.projectlombok:lombok:1.18.36' + testAnnotationProcessor 'org.projectlombok:lombok:1.18.36' + } + + tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + } + + tasks { + test { + useJUnitPlatform() + } + } +} + + + +jar { + enabled = false +} \ No newline at end of file diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml new file mode 100644 index 000000000..9e9496232 --- /dev/null +++ b/docker/docker-compose.yaml @@ -0,0 +1,83 @@ +version: '3.8' +services: + redis-movie: + image: mysql:9.1.0 + container_name: cinema-mysql + restart: always + ports: + - "3309:3306" + volumes: + - ./db/conf.d:/etc/mysql/conf.d + - ./db/data:/var/lib/mysql + - ./db/initdb.d:/docker-entrypoint-initdb.d + environment: + - TZ=Asia/Seoul + - MYSQL_ROOT_PASSWORD=1234 + - MYSQL_DATABASE=redis-movie + - MYSQL_USER=hongs + - MYSQL_PASSWORD=local1234 + command: > + --character-set-server=utf8mb4 + --collation-server=utf8mb4_unicode_ci + + redis: + image: redis + container_name: redis-container + ports: + - "6380:6379" + + + influxdb: + image: influxdb:2.7.5 + networks: + - monitoring + ports: + - "8086:8086" + volumes: + - ./influxdb/data:/var/lib/influxdb2 + - ./influxdb/config:/etc/influxdb2 + environment: + - DOCKER_INFLUXDB_INIT_MODE=setup + - DOCKER_INFLUXDB_INIT_USERNAME=hongs + - DOCKER_INFLUXDB_INIT_PASSWORD=local1234 + - DOCKER_INFLUXDB_INIT_ORG=hongs + - DOCKER_INFLUXDB_INIT_BUCKET=redis-cinema + - DOCKER_INFLUXDB_INIT_ADMIN_TOKEN=EsM8u2l07RkAdG8pLE0adgUtL2tXBrdWqVtQyb1rBv4MnRuza8UF4hNYlzzFakyF66Cw9WnRyGLepctBa5DBWQ== +# - DOCKER_INFLUXDB_INIT_RETENTION=1d # 데이터 보존 기간 (24시간) => default : 0 (infinite) + + k6: + image: k6-custom:latest + container_name: cinema-k6-load-test + restart: always + networks: + - monitoring + ports: + - "6565:6565" + environment: + - K6_OUT=xk6-influxdb=http://influxdb:8086 + - K6_INFLUXDB_ORGANIZATION=hongs + - K6_INFLUXDB_BUCKET=redis-cinema + - K6_INFLUXDB_INSECURE=true + - K6_INFLUXDB_TOKEN=EsM8u2l07RkAdG8pLE0adgUtL2tXBrdWqVtQyb1rBv4MnRuza8UF4hNYlzzFakyF66Cw9WnRyGLepctBa5DBWQ== + volumes: + - ./k6/scripts:/scripts + entrypoint: ["tail", "-f", "/dev/null"] # 컨테이너 실행 후 대기 상태 유지 + + + grafana: + image: grafana/grafana:8.2.6 + ports: + - "3000:3000" + networks: + - monitoring + environment: + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_BASIC_ENABLED=false + volumes: + - ./grafana/grafana_data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning + +networks: + monitoring: + driver: bridge \ No newline at end of file diff --git a/docker/k6/scripts/load_test.js b/docker/k6/scripts/load_test.js new file mode 100644 index 000000000..5fa86fff0 --- /dev/null +++ b/docker/k6/scripts/load_test.js @@ -0,0 +1,25 @@ +import http from 'k6/http'; +import { check } from 'k6'; + +export const options = { + stages: [ + { duration: '1m', target: 10 }, // 1분 동안 10명의 VU + { duration: '2m', target: 50 }, // 2분 동안 50명의 VU 유지 + { duration: '1m', target: 0 }, // 1분 동안 VU 감소 + ], + thresholds: { + http_req_duration: ['p(95)<500'], // 95%의 요청이 500ms 이내여야 함 + 'http_reqs': ['rate>50'], // 초당 50개의 요청 이상 처리 + }, +}; + +export default function () { + const url = 'http://host.docker.internal:8080/api/v1/screenings/redis'; // 테스트할 엔드포인트 + + const response = http.get(url); + + check(response, { + "status is 200": (r) => r.status === 200, + "response time is acceptable": (r) => r.timings.duration < 500, + }); +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 000000000..1fab15cd4 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +#org.gradle.configuration-cache=true +#org.gradle.parallel=true +#org.gradle.caching=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 000000000..4ac3234a6 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,2 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format 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/http/getScreenings.http b/http/getScreenings.http new file mode 100644 index 000000000..dc7f3c6db --- /dev/null +++ b/http/getScreenings.http @@ -0,0 +1,27 @@ +### GET request to example server +GET https://examples.http-client.intellij.net/get + ?generated-in=IntelliJ IDEA + +### GET screenings (기본 2일) +GET http://localhost:8080/api/v1/screenings?genreName=판타지 +Content-Type: application/json + + +### GET screenings (3일 이내 상영 영화 목록 가능) +GET http://localhost:8080/api/v1/screenings?maxScreeningDay=3 +Content-Type: application/json + + +### GET screenings (기본 2일 + 판타지 장르) +GET http://localhost:8080/api/v1/screenings?maxScreeningDay=3&genreName=판타지 +Content-Type: application/json + + +### GET screenings (기본 2일 + 판타지 장르 + local cache) +GET http://localhost:8080/api/v2/screenings/local-caching?maxScreeningDay=3&genreName=판타지 +Content-Type: application/json + + +### GET screenings (기본 2일 + 판타지 장르 + redis) +GET http://localhost:8080/api/v3/screenings/redis?maxScreeningDay=3&genreName=판타지 +Content-Type: application/json \ No newline at end of file diff --git a/img.png b/img.png new file mode 100644 index 000000000..6fec5a76b Binary files /dev/null and b/img.png differ diff --git a/module-application/README.md b/module-application/README.md new file mode 100644 index 000000000..9dd3af66e --- /dev/null +++ b/module-application/README.md @@ -0,0 +1,105 @@ +## [Application Module] + +### 책임 + +1. **도메인 객체를 통해 Application 흐름 제어** + - 도메인 간 상호작용을 조율하며 비즈니스 로직의 흐름을 제어. + - 각 도메인 서비스와 협력하여 비즈니스 요구사항을 처리. + +2. **Infrastructure 모듈과의 분리** + - 데이터베이스와의 직접적인 통신은 Infrastructure 모듈에 위임. + - `ScreeningQueryPort`와 같은 인터페이스를 통해 Application 계층은 외부 기술에 독립적. + +3. **외부 기술과 무관한 설계** + - Infrastructure에 종속되지 않으며, 비즈니스 로직의 흐름에만 집중. + - 외부 기술 변경(JPA -> NoSQL 등)이 Application 계층에 영향을 미치지 않도록 설계. + +--- + +### 구조 + +- **Port-Adapter 패턴 적용**: + - Application 계층은 Port(인터페이스)를 정의하며, 실제 구현은 Infrastructure 모듈에서 Adapter로 처리. + - 예: `ScreeningQueryPort`를 통해 데이터 소스를 추상화. + +- **도메인 간 상호작용**: + - 비즈니스 로직은 도메인 객체와 도메인 서비스를 활용하여 처리. + - 데이터 변환 및 조율을 담당. + +--- + +### 주요 클래스 + +#### **`ScreeningQueryService`** +- 비즈니스 로직의 흐름을 제어하는 서비스 클래스. +- Port 인터페이스(`ScreeningQueryPort`)를 통해 Infrastructure 모듈과 통신. + +```java +@Service +@RequiredArgsConstructor +public class ScreeningQueryService implements ScreeningQueryUseCase { + + private final ScreeningQueryPort screeningQueryPort; + + @Override + public List getScreenings(ScreeningsQueryParam param) { + return screeningQueryPort.getScreenings(param.getMaxScreeningDay()); + } +} +``` + +--- + +#### **`ScreeningQueryUseCase`** +- Application 계층의 핵심 인터페이스. +- Presentation 계층이 이를 통해 비즈니스 로직에 접근. + +```java +public interface ScreeningQueryUseCase { + + List getScreenings(ScreeningsQueryParam param); + +} +``` + +--- + +#### **`ScreeningsQueryParam`** +- 비즈니스 로직 수행에 필요한 파라미터를 캡슐화한 클래스. + +```java +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ScreeningsQueryParam { + private int maxScreeningDay; +} +``` + +--- + +### 설계 원칙 + +1. **Port-Adapter 패턴 준수**: + - Port(인터페이스): `ScreeningQueryPort`. + - Adapter(구현체): Infrastructure 모듈에서 구현. + +2. **도메인 중심 설계**: + - 비즈니스 로직은 도메인 객체 및 도메인 서비스와 협력하여 처리. + - Application 계층은 비즈니스 흐름에 집중. + +3. **기술 독립성**: + - Application 계층은 외부 기술(JPA, DB 등)에 종속되지 않으며, 순수 Java 로직으로 구성. + +4. **확장성**: + - 새로운 데이터 소스나 비즈니스 요구사항이 추가되더라도 Port와 Adapter를 통해 확장 가능. + +--- + +### 장점 +- **유지보수성**: 외부 기술 변경 시 Application 계층에는 영향을 미치지 않음. +- **테스트 용이성**: Port를 Mocking하여 테스트 가능. +- **독립성**: 기술과 무관한 순수 비즈니스 로직 유지. + +--- diff --git a/module-application/build.gradle b/module-application/build.gradle new file mode 100644 index 000000000..393efc34d --- /dev/null +++ b/module-application/build.gradle @@ -0,0 +1,29 @@ +plugins { + id 'org.springframework.boot' version '3.4.1' +} + +jar { + enabled = true +} + +bootJar { + enabled = false +} + +bootRun { + enabled = false +} + + +group = 'project.redis.application' +version = '0.0.1-SNAPSHOT' + +dependencies { + implementation project(':module-domain') + implementation project(':module-common') + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} \ No newline at end of file diff --git a/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaCommandUseCase.java b/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaCommandUseCase.java new file mode 100644 index 000000000..190c682ca --- /dev/null +++ b/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaCommandUseCase.java @@ -0,0 +1,6 @@ +package project.redis.application.cinema.port.inbound; + +public interface CinemaCommandUseCase { + + void createCinema(CinemaCreateCommandParam param); +} diff --git a/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaCreateCommandParam.java b/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaCreateCommandParam.java new file mode 100644 index 000000000..69985bc2b --- /dev/null +++ b/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaCreateCommandParam.java @@ -0,0 +1,23 @@ +package project.redis.application.cinema.port.inbound; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Value; +import project.redis.common.SelfValidating; + +@Getter +@Value +@EqualsAndHashCode(callSuper = false) +public class CinemaCreateCommandParam extends SelfValidating { + + @NotNull(message = "COMMON.ERROR.NOT_NULL") + @NotBlank(message = "COMMON.ERROR.NOT_BLANK") + String cinemaName; + + public CinemaCreateCommandParam(String cinemaName) { + this.cinemaName = cinemaName; + this.validate(); + } +} diff --git a/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaQueryUseCase.java b/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaQueryUseCase.java new file mode 100644 index 000000000..e0b1a7f5b --- /dev/null +++ b/module-application/src/main/java/project/redis/application/cinema/port/inbound/CinemaQueryUseCase.java @@ -0,0 +1,9 @@ +package project.redis.application.cinema.port.inbound; + +import java.util.List; +import project.redis.domain.cinema.Cinema; + +public interface CinemaQueryUseCase { + + List getCinemas(); +} diff --git a/module-application/src/main/java/project/redis/application/cinema/port/outbound/CinemaCommandPort.java b/module-application/src/main/java/project/redis/application/cinema/port/outbound/CinemaCommandPort.java new file mode 100644 index 000000000..e05a02c76 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/cinema/port/outbound/CinemaCommandPort.java @@ -0,0 +1,6 @@ +package project.redis.application.cinema.port.outbound; + +public interface CinemaCommandPort { + + void createCinema(String cinemaName) throws IllegalArgumentException; +} diff --git a/module-application/src/main/java/project/redis/application/cinema/port/outbound/CinemaQueryPort.java b/module-application/src/main/java/project/redis/application/cinema/port/outbound/CinemaQueryPort.java new file mode 100644 index 000000000..d9593bfb3 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/cinema/port/outbound/CinemaQueryPort.java @@ -0,0 +1,9 @@ +package project.redis.application.cinema.port.outbound; + +import java.util.List; +import project.redis.domain.cinema.Cinema; + +public interface CinemaQueryPort { + + List getCinemas(); +} diff --git a/module-application/src/main/java/project/redis/application/cinema/service/CinemaCommandService.java b/module-application/src/main/java/project/redis/application/cinema/service/CinemaCommandService.java new file mode 100644 index 000000000..8de6e63d1 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/cinema/service/CinemaCommandService.java @@ -0,0 +1,21 @@ +package project.redis.application.cinema.service; + + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import project.redis.application.cinema.port.inbound.CinemaCommandUseCase; +import project.redis.application.cinema.port.inbound.CinemaCreateCommandParam; +import project.redis.application.cinema.port.outbound.CinemaCommandPort; + + +@Service +@RequiredArgsConstructor +public class CinemaCommandService implements CinemaCommandUseCase { + + private final CinemaCommandPort cinemaCommandPort; + + @Override + public void createCinema(CinemaCreateCommandParam param) { + cinemaCommandPort.createCinema(param.getCinemaName()); + } +} diff --git a/module-application/src/main/java/project/redis/application/cinema/service/CinemaQueryService.java b/module-application/src/main/java/project/redis/application/cinema/service/CinemaQueryService.java new file mode 100644 index 000000000..ee6fd43cb --- /dev/null +++ b/module-application/src/main/java/project/redis/application/cinema/service/CinemaQueryService.java @@ -0,0 +1,21 @@ +package project.redis.application.cinema.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import project.redis.application.cinema.port.inbound.CinemaQueryUseCase; +import project.redis.application.cinema.port.outbound.CinemaQueryPort; +import project.redis.domain.cinema.Cinema; + + +@Service +@RequiredArgsConstructor +public class CinemaQueryService implements CinemaQueryUseCase { + + private final CinemaQueryPort cinemaQueryPort; + + @Override + public List getCinemas() { + return cinemaQueryPort.getCinemas(); + } +} diff --git a/module-application/src/main/java/project/redis/application/genre/port/inbound/GenreQueryUseCase.java b/module-application/src/main/java/project/redis/application/genre/port/inbound/GenreQueryUseCase.java new file mode 100644 index 000000000..ad6835a59 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/genre/port/inbound/GenreQueryUseCase.java @@ -0,0 +1,9 @@ +package project.redis.application.genre.port.inbound; + +import java.util.List; +import project.redis.domain.genre.Genre; + +public interface GenreQueryUseCase { + + List getGenres(); +} diff --git a/module-application/src/main/java/project/redis/application/genre/port/outbound/GenreQueryPort.java b/module-application/src/main/java/project/redis/application/genre/port/outbound/GenreQueryPort.java new file mode 100644 index 000000000..f4d2096f4 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/genre/port/outbound/GenreQueryPort.java @@ -0,0 +1,9 @@ +package project.redis.application.genre.port.outbound; + +import java.util.List; +import project.redis.domain.genre.Genre; + +public interface GenreQueryPort { + + List findAllGenres(); +} diff --git a/module-application/src/main/java/project/redis/application/genre/service/GenreQueryService.java b/module-application/src/main/java/project/redis/application/genre/service/GenreQueryService.java new file mode 100644 index 000000000..f643e0eb7 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/genre/service/GenreQueryService.java @@ -0,0 +1,20 @@ +package project.redis.application.genre.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import project.redis.application.genre.port.inbound.GenreQueryUseCase; +import project.redis.application.genre.port.outbound.GenreQueryPort; +import project.redis.domain.genre.Genre; + +@Service +@RequiredArgsConstructor +public class GenreQueryService implements GenreQueryUseCase { + + private final GenreQueryPort genreQueryPort; + + @Override + public List getGenres() { + return List.of(); + } +} diff --git a/module-application/src/main/java/project/redis/application/movie/port/inbound/MovieQueryUseCase.java b/module-application/src/main/java/project/redis/application/movie/port/inbound/MovieQueryUseCase.java new file mode 100644 index 000000000..e7dcc419b --- /dev/null +++ b/module-application/src/main/java/project/redis/application/movie/port/inbound/MovieQueryUseCase.java @@ -0,0 +1,9 @@ +package project.redis.application.movie.port.inbound; + +import java.util.List; +import project.redis.domain.movie.Movie; + +public interface MovieQueryUseCase { + + List getMovies(); +} diff --git a/module-application/src/main/java/project/redis/application/movie/port/outbound/MovieQueryPort.java b/module-application/src/main/java/project/redis/application/movie/port/outbound/MovieQueryPort.java new file mode 100644 index 000000000..6dee94ebd --- /dev/null +++ b/module-application/src/main/java/project/redis/application/movie/port/outbound/MovieQueryPort.java @@ -0,0 +1,8 @@ +package project.redis.application.movie.port.outbound; + +import java.util.List; +import project.redis.domain.movie.Movie; + +public interface MovieQueryPort { + List getMovies(); +} diff --git a/module-application/src/main/java/project/redis/application/movie/service/MovieQueryService.java b/module-application/src/main/java/project/redis/application/movie/service/MovieQueryService.java new file mode 100644 index 000000000..794faa489 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/movie/service/MovieQueryService.java @@ -0,0 +1,21 @@ +package project.redis.application.movie.service; + + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import project.redis.application.movie.port.inbound.MovieQueryUseCase; +import project.redis.application.movie.port.outbound.MovieQueryPort; +import project.redis.domain.movie.Movie; + +@Service +@RequiredArgsConstructor +public class MovieQueryService implements MovieQueryUseCase { + + private final MovieQueryPort movieQueryPort; + + @Override + public List getMovies() { + return movieQueryPort.getMovies(); + } +} diff --git a/module-application/src/main/java/project/redis/application/reservation/port/inbound/ReservationCommandUseCase.java b/module-application/src/main/java/project/redis/application/reservation/port/inbound/ReservationCommandUseCase.java new file mode 100644 index 000000000..a4f923030 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/reservation/port/inbound/ReservationCommandUseCase.java @@ -0,0 +1,6 @@ +package project.redis.application.reservation.port.inbound; + +public interface ReservationCommandUseCase { + + boolean reserve(ReserveCommandParam param); +} diff --git a/module-application/src/main/java/project/redis/application/reservation/port/inbound/ReserveCommandParam.java b/module-application/src/main/java/project/redis/application/reservation/port/inbound/ReserveCommandParam.java new file mode 100644 index 000000000..c0c0785c7 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/reservation/port/inbound/ReserveCommandParam.java @@ -0,0 +1,33 @@ +package project.redis.application.reservation.port.inbound; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import java.util.UUID; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Value; +import project.redis.common.SelfValidating; + +@Getter +@Value +@EqualsAndHashCode(callSuper = false) +public class ReserveCommandParam extends SelfValidating { + + @NotNull + @Size(min = 1, max = 5) + List seatIds; + + @NotNull + UUID screeningId; + + @NotNull + String userName; + + public ReserveCommandParam(List seatIds, UUID screeningId, String userName) { + this.seatIds = seatIds; + this.screeningId = screeningId; + this.userName = userName; + validate(); + } +} diff --git a/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationCommandPort.java b/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationCommandPort.java new file mode 100644 index 000000000..8424db0ef --- /dev/null +++ b/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationCommandPort.java @@ -0,0 +1,7 @@ +package project.redis.application.reservation.port.outbound; + +import project.redis.domain.reservation.Reservation; + +public interface ReservationCommandPort { + void reserve(Reservation reservation); +} diff --git a/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationLockPort.java b/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationLockPort.java new file mode 100644 index 000000000..3c223ceb4 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationLockPort.java @@ -0,0 +1,13 @@ +package project.redis.application.reservation.port.outbound; + +import java.util.List; + +public interface ReservationLockPort { + boolean tryLock(String lockKey, long waitTimeMils, long releaseTimeMils); + + boolean tryScreeningSeatLock(List lockKeys, long waitTimeMils, long releaseTimeMils); + + void releaseLock(String lockKey); + + void releaseMultiLock(List lockKeys); +} diff --git a/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationQueryPort.java b/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationQueryPort.java new file mode 100644 index 000000000..6e2f67729 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/reservation/port/outbound/ReservationQueryPort.java @@ -0,0 +1,10 @@ +package project.redis.application.reservation.port.outbound; + +import java.util.List; +import java.util.UUID; +import project.redis.domain.reservation.Reservation; + +public interface ReservationQueryPort { + + List getReservations(UUID screeningId); +} diff --git a/module-application/src/main/java/project/redis/application/reservation/service/ReservationCommandService.java b/module-application/src/main/java/project/redis/application/reservation/service/ReservationCommandService.java new file mode 100644 index 000000000..dcc3fd99e --- /dev/null +++ b/module-application/src/main/java/project/redis/application/reservation/service/ReservationCommandService.java @@ -0,0 +1,111 @@ +package project.redis.application.reservation.service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; +import project.redis.application.reservation.port.inbound.ReservationCommandUseCase; +import project.redis.application.reservation.port.inbound.ReserveCommandParam; +import project.redis.application.reservation.port.outbound.ReservationCommandPort; +import project.redis.application.reservation.port.outbound.ReservationLockPort; +import project.redis.application.reservation.port.outbound.ReservationQueryPort; +import project.redis.application.screening.port.outbound.ScreeningQueryPort; +import project.redis.application.seat.port.outbound.SeatQueryPort; +import project.redis.common.exception.DataInvalidException; +import project.redis.common.exception.ErrorCode; +import project.redis.domain.reservation.Reservation; +import project.redis.domain.screening.Screening; +import project.redis.domain.seat.Seat; + + +@Slf4j +@Service +@RequiredArgsConstructor +public class ReservationCommandService implements ReservationCommandUseCase { + + private final SeatQueryPort seatQueryPort; + private final ReservationQueryPort reservationQueryPort; + private final ScreeningQueryPort screeningQueryPort; + private final ReservationCommandPort reservationCommandPort; + + private final ReservationLockPort reservationLockPort; + + /* + 적용 비지니스 규칙 + 1. 들어온 좌석은 연속된 좌석이어야 한다. + 2. 이미 예약이 존재하는 좌석은 예약이 불가능하다. + 3. 사용자의 예약은 모두 연속된 좌석이어야 한다. + 4. 사용자는 최대 해당 상영관에 대해서 5개까지 예약이 가능하다. + */ + @Override + public boolean reserve(ReserveCommandParam param) { + List seatIds = param.getSeatIds().stream().map(String::valueOf).toList(); + boolean lock = reservationLockPort.tryScreeningSeatLock( + makeLockKey(param.getScreeningId().toString(), seatIds), + 20, + 1000); + + if (!lock) { + log.info("locking screening for seat {} failed", param.getScreeningId()); + throw new DataInvalidException(ErrorCode.SEAT_ALREADY_RESERVED, param.getSeatIds().toString()); + } + + try { + // 연속된 좌석인지 여부 + List seats = seatQueryPort.getSeats(param.getSeatIds()); + if (!Seat.isSeries(seats)) { + throw new DataInvalidException(ErrorCode.SEAT_REQUIRED_SERIES); + } + + // 예약 가져오기 + List originReservations = reservationQueryPort.getReservations(param.getScreeningId()); + + List seatList = originReservations.stream() + .flatMap(reservation -> reservation.getSeats().stream()) + .map(Seat::getSeatId) + .collect(Collectors.toList()); + + // 이미 예약이 존재하는 좌석인지 검증 + boolean isAlreadyReservation = seatList.retainAll(param.getSeatIds()); + + if( isAlreadyReservation ) { + throw new DataInvalidException(ErrorCode.SEAT_ALREADY_RESERVED, seatList.toString()); + } + + List originSeats = originReservations.stream() + .filter(reservation -> reservation.getUsername().equals(param.getUserName())) + .flatMap(reservation -> reservation.getSeats().stream()) + .toList(); + + // 이전 예약 + 현재 예약하려는 좌석의 연속성 검증 && 5개 이하의 예약 검증 + Seat.isAvailable(originSeats, seats); + + Screening screening = !CollectionUtils.isEmpty(originReservations) + ? originReservations.getFirst().getScreening() + : screeningQueryPort.getScreening(param.getScreeningId()); + + if (!screening.isLaterScreening()) { + throw new DataInvalidException(ErrorCode.SCREENING_REQUIRED_LATER_NOW, param.getScreeningId()); + } + + Reservation reservation + = Reservation.generateReservation( + null, LocalDateTime.now(), param.getUserName(), screening, seats); + + reservationCommandPort.reserve(reservation); + return true; + } finally { + reservationLockPort.releaseMultiLock(makeLockKey(param.getScreeningId().toString(), seatIds)); + } + } + + private List makeLockKey(String screeningId, List list) { + return list.stream() + .map(seatId -> "reservation-lock:" + screeningId + ":" + seatId) + .toList(); + } +} \ No newline at end of file diff --git a/module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningQueryUseCase.java b/module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningQueryUseCase.java new file mode 100644 index 000000000..d8118f4b8 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningQueryUseCase.java @@ -0,0 +1,14 @@ +package project.redis.application.screening.port.inbound; + +import java.util.List; +import project.redis.domain.screening.Screening; + +public interface ScreeningQueryUseCase { + + List getScreenings(ScreeningsQueryParam param); + + List getScreeningsLocalCache(ScreeningsQueryParam param); + + List getScreeningsRedis(ScreeningsQueryParam param); + +} diff --git a/module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningsQueryParam.java b/module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningsQueryParam.java new file mode 100644 index 000000000..2269c4fa1 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/screening/port/inbound/ScreeningsQueryParam.java @@ -0,0 +1,18 @@ +package project.redis.application.screening.port.inbound; + +import java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ScreeningsQueryParam { + private int maxScreeningDay; + private String movieName; + private String genreName; +} diff --git a/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryFilter.java b/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryFilter.java new file mode 100644 index 000000000..fcb29023b --- /dev/null +++ b/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryFilter.java @@ -0,0 +1,33 @@ +package project.redis.application.screening.port.outbound; + +import java.util.Objects; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ScreeningQueryFilter { + private int maxScreeningDay; + private String movieName; + private String genreName; + + @Override + public int hashCode() { + return Objects.hash(maxScreeningDay, genreName, movieName); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + ScreeningQueryFilter that = (ScreeningQueryFilter) o; + + return maxScreeningDay == that.maxScreeningDay && + Objects.equals(genreName, that.genreName) && + Objects.equals(movieName, that.movieName); + } +} diff --git a/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryPort.java b/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryPort.java new file mode 100644 index 000000000..442e93614 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/screening/port/outbound/ScreeningQueryPort.java @@ -0,0 +1,16 @@ +package project.redis.application.screening.port.outbound; + +import java.util.List; +import java.util.UUID; +import project.redis.domain.screening.Screening; + +public interface ScreeningQueryPort { + + List getScreenings(ScreeningQueryFilter filter); + + List getScreeningsRedis(ScreeningQueryFilter filter); + + List getScreeningsLocalCache(ScreeningQueryFilter filter); + + Screening getScreening(UUID screeningId); +} diff --git a/module-application/src/main/java/project/redis/application/screening/service/ScreeningQueryService.java b/module-application/src/main/java/project/redis/application/screening/service/ScreeningQueryService.java new file mode 100644 index 000000000..a371ea450 --- /dev/null +++ b/module-application/src/main/java/project/redis/application/screening/service/ScreeningQueryService.java @@ -0,0 +1,54 @@ +package project.redis.application.screening.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import project.redis.application.screening.port.inbound.ScreeningQueryUseCase; +import project.redis.application.screening.port.inbound.ScreeningsQueryParam; +import project.redis.application.screening.port.outbound.ScreeningQueryFilter; +import project.redis.application.screening.port.outbound.ScreeningQueryPort; +import project.redis.domain.screening.Screening; + + +@Service +@RequiredArgsConstructor +public class ScreeningQueryService implements ScreeningQueryUseCase { + + + private final ScreeningQueryPort screeningQueryPort; + + + @Override + public List getScreenings(ScreeningsQueryParam param) { + return screeningQueryPort.getScreenings( + ScreeningQueryFilter.builder() + .maxScreeningDay(param.getMaxScreeningDay()) + .genreName(param.getGenreName()) + .movieName(param.getMovieName()) + .build() + ); + } + + + @Override + public List getScreeningsRedis(ScreeningsQueryParam param) { + return screeningQueryPort.getScreeningsRedis( + ScreeningQueryFilter.builder() + .maxScreeningDay(param.getMaxScreeningDay()) + .genreName(param.getGenreName()) + .movieName(param.getMovieName()) + .build() + ); + } + + @Override + public List getScreeningsLocalCache(ScreeningsQueryParam param) { + return screeningQueryPort.getScreeningsLocalCache( + ScreeningQueryFilter.builder() + .maxScreeningDay(param.getMaxScreeningDay()) + .genreName(param.getGenreName()) + .movieName(param.getMovieName()) + .build() + ); + } +} diff --git a/module-application/src/main/java/project/redis/application/seat/port/outbound/SeatQueryPort.java b/module-application/src/main/java/project/redis/application/seat/port/outbound/SeatQueryPort.java new file mode 100644 index 000000000..f93e1101d --- /dev/null +++ b/module-application/src/main/java/project/redis/application/seat/port/outbound/SeatQueryPort.java @@ -0,0 +1,10 @@ +package project.redis.application.seat.port.outbound; + +import java.util.List; +import java.util.UUID; +import project.redis.domain.seat.Seat; + +public interface SeatQueryPort { + + List getSeats(List seatIds); +} diff --git a/module-application/src/test/java/project/redis/application/cinema/service/CinemaCommandServiceTest.java b/module-application/src/test/java/project/redis/application/cinema/service/CinemaCommandServiceTest.java new file mode 100644 index 000000000..55249051e --- /dev/null +++ b/module-application/src/test/java/project/redis/application/cinema/service/CinemaCommandServiceTest.java @@ -0,0 +1,55 @@ +package project.redis.application.cinema.service; + + +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import project.redis.application.cinema.port.inbound.CinemaCreateCommandParam; +import project.redis.application.cinema.port.outbound.CinemaCommandPort; + +@ExtendWith(MockitoExtension.class) +class CinemaCommandServiceTest { + + @Mock + CinemaCommandPort cinemaCommandPort; + + @InjectMocks + CinemaCommandService cinemaCommandService; + + + @DisplayName("상영관 생성 - 성공") + @Test + void testCreateCinema() { + String cinemaName = "cinema"; + CinemaCreateCommandParam param = new CinemaCreateCommandParam(cinemaName); + + doNothing().when(cinemaCommandPort).createCinema(param.getCinemaName()); + + cinemaCommandService.createCinema(param); + + verify(cinemaCommandPort).createCinema(param.getCinemaName()); + } + + @DisplayName("상영관 생성 - 실패 - 이미 존재하는 상영관 이름") + @Test + void testCreateCinemaWithInvalidCinemaName() { + String cinemaName = "cinema"; + CinemaCreateCommandParam param = new CinemaCreateCommandParam(cinemaName); + + doThrow(IllegalArgumentException.class) + .when(cinemaCommandPort).createCinema(param.getCinemaName()); + + Assertions.assertThrows(IllegalArgumentException.class, () -> cinemaCommandService.createCinema(param)); + + verify(cinemaCommandPort).createCinema(param.getCinemaName()); + } + +} \ No newline at end of file diff --git a/module-application/src/test/java/project/redis/application/reservation/service/ReservationCommandServiceTest.java b/module-application/src/test/java/project/redis/application/reservation/service/ReservationCommandServiceTest.java new file mode 100644 index 000000000..e3bd348bb --- /dev/null +++ b/module-application/src/test/java/project/redis/application/reservation/service/ReservationCommandServiceTest.java @@ -0,0 +1,143 @@ +package project.redis.application.reservation.service; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +import jakarta.validation.ConstraintViolationException; +import java.util.List; +import java.util.UUID; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import project.redis.application.reservation.port.inbound.ReserveCommandParam; +import project.redis.application.reservation.port.outbound.ReservationQueryPort; +import project.redis.application.seat.port.outbound.SeatQueryPort; +import project.redis.common.exception.DataInvalidException; +import project.redis.domain.cinema.Cinema; +import project.redis.domain.reservation.Reservation; +import project.redis.domain.seat.Seat; + +@ExtendWith(MockitoExtension.class) +class ReservationCommandServiceTest { + + @Mock + SeatQueryPort seatQueryPort; + + @Mock + ReservationQueryPort reservationQueryPort; + + @InjectMocks + ReservationCommandService reservationCommandService; + + @Test + void testSeriesSeatNoSeriesSeat() { + // Arrange + UUID cinemaId = UUID.randomUUID(); + Cinema cinema = Cinema.generateCinema(cinemaId, "Test Cinema"); + + Seat seat1 = Seat.generateSeat(UUID.randomUUID(), "A1", cinema); + Seat seat2 = Seat.generateSeat(UUID.randomUUID(), "A2", cinema); + Seat seat3 = Seat.generateSeat(UUID.randomUUID(), "A4", cinema); + + List seats = List.of(seat1, seat2, seat3); + + List seatIds = seats.stream().map(Seat::getSeatId).toList(); + ReserveCommandParam param = new ReserveCommandParam(seatIds, + UUID.randomUUID(), "user"); + + when(seatQueryPort.getSeats(seatIds)).thenReturn(seats); + + Assertions.assertThatThrownBy(() -> reservationCommandService.reserve(param)); + + + } + + @Test + void testSeriesSeatInputModelNoValidate() { + // Arrange + UUID cinemaId = UUID.randomUUID(); + Cinema cinema = Cinema.generateCinema(cinemaId, "Test Cinema"); + + Seat seat1 = Seat.generateSeat(UUID.randomUUID(), "A1", cinema); + Seat seat2 = Seat.generateSeat(UUID.randomUUID(), "A2", cinema); + Seat seat3 = Seat.generateSeat(UUID.randomUUID(), "A4", cinema); + Seat seat4 = Seat.generateSeat(UUID.randomUUID(), "A4", cinema); + Seat seat5 = Seat.generateSeat(UUID.randomUUID(), "A4", cinema); + Seat seat6 = Seat.generateSeat(UUID.randomUUID(), "A4", cinema); + + List seats = List.of(seat1, seat2, seat3, seat4, seat5, seat6); + + List seatIds = seats.stream().map(Seat::getSeatId).toList(); + + assertThrows(ConstraintViolationException.class, () -> new ReserveCommandParam(seatIds, UUID.randomUUID(), "user")); + } + + @Test + void testAlreadyReservedSeat() { + // given + UUID cinemaId = UUID.randomUUID(); + Cinema cinema = Cinema.generateCinema(cinemaId, "Test Cinema"); + UUID seat4Id = UUID.randomUUID(); + UUID seat2Id = UUID.randomUUID(); + + Seat seat1 = Seat.generateSeat(UUID.randomUUID(), "A1", cinema); + Seat seat2 = Seat.generateSeat(seat2Id, "A2", cinema); + + UUID screeningId = UUID.randomUUID(); + + Seat seat3 = Seat.generateSeat(seat2Id, "A2", cinema); + Seat seat4 = Seat.generateSeat(seat4Id, "A3", cinema); + List seats = List.of(seat3, seat4); + + List seatIds = seats.stream().map(Seat::getSeatId).toList(); + ReserveCommandParam param = new ReserveCommandParam(seatIds, + screeningId, "user"); + + Reservation reservation = Reservation.generateReservation( + null, null, null, null, List.of(seat1, seat2)); + + when(seatQueryPort.getSeats(List.of(seat2Id, seat4Id))).thenReturn(List.of(seat3, seat4)); + when(reservationQueryPort.getReservations(screeningId)).thenReturn(List.of(reservation)); + + + assertThrows(DataInvalidException.class, () -> reservationCommandService.reserve(param)); + } + + + + @Test + void testAlreadyReservationSeat5Exceed() { + // given + UUID cinemaId = UUID.randomUUID(); + UUID screeningId = UUID.randomUUID(); + Cinema cinema = Cinema.generateCinema(cinemaId, "Test Cinema"); + + Seat seat1 = Seat.generateSeat(UUID.randomUUID(), "A1", cinema); + Seat seat2 = Seat.generateSeat(UUID.randomUUID(), "A2", cinema); + Seat seat3 = Seat.generateSeat(UUID.randomUUID(), "A3", cinema); + Seat seat4 = Seat.generateSeat(UUID.randomUUID(), "A4", cinema); + Seat seat5 = Seat.generateSeat(UUID.randomUUID(), "A5", cinema); + + List alreadyReservedSeats = List.of(seat1, seat2, seat3, seat4, seat5); + + Seat seat6 = Seat.generateSeat(UUID.randomUUID(), "B5", cinema); + + ReserveCommandParam param = new ReserveCommandParam(List.of(seat6.getSeatId()), + screeningId, "user"); + + + Reservation reservation = Reservation.generateReservation( + null, null, null, null, alreadyReservedSeats); + + // when + when(seatQueryPort.getSeats(List.of(seat6.getSeatId()))).thenReturn(List.of(seat6)); + when(reservationQueryPort.getReservations(screeningId)).thenReturn(List.of(reservation)); + + + assertThrows(DataInvalidException.class, () -> reservationCommandService.reserve(param)); + } + +} \ No newline at end of file diff --git a/module-common/build.gradle b/module-common/build.gradle new file mode 100644 index 000000000..bd2fe950c --- /dev/null +++ b/module-common/build.gradle @@ -0,0 +1,18 @@ +plugins { + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'project.redis.common' +version = '0.0.1-SNAPSHOT' + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-validation' + + testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation("org.junit.jupiter:junit-jupiter-params") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") + + testImplementation("org.assertj:assertj-core") + testImplementation("org.mockito:mockito-core") + testImplementation("org.mockito:mockito-junit-jupiter") +} \ No newline at end of file diff --git a/module-common/src/main/java/project/redis/common/SelfValidating.java b/module-common/src/main/java/project/redis/common/SelfValidating.java new file mode 100644 index 000000000..36e578e47 --- /dev/null +++ b/module-common/src/main/java/project/redis/common/SelfValidating.java @@ -0,0 +1,25 @@ +package project.redis.common; + +import static jakarta.validation.Validation.buildDefaultValidatorFactory; + +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; +import jakarta.validation.Validator; +import jakarta.validation.ValidatorFactory; +import java.util.Set; + +public abstract class SelfValidating { + private final Validator validator; + + public SelfValidating() { + ValidatorFactory factory = buildDefaultValidatorFactory(); + validator = factory.getValidator(); + } + + protected void validate() { + Set> violations = validator.validate((T) this); + if (!violations.isEmpty()) { + throw new ConstraintViolationException(violations); + } + } +} diff --git a/module-common/src/main/java/project/redis/common/exception/DataInvalidException.java b/module-common/src/main/java/project/redis/common/exception/DataInvalidException.java new file mode 100644 index 000000000..7e47a0ed8 --- /dev/null +++ b/module-common/src/main/java/project/redis/common/exception/DataInvalidException.java @@ -0,0 +1,16 @@ +package project.redis.common.exception; + + +import lombok.Getter; + +@Getter +public class DataInvalidException extends RuntimeException { + private final ErrorCode errorCode; + private final Object[] args; + + public DataInvalidException(ErrorCode errorCode, Object... args) { + super(errorCode.getMessageId()); + this.errorCode = errorCode; + this.args = args; + } +} diff --git a/module-common/src/main/java/project/redis/common/exception/ErrorCode.java b/module-common/src/main/java/project/redis/common/exception/ErrorCode.java new file mode 100644 index 000000000..22e3e92e4 --- /dev/null +++ b/module-common/src/main/java/project/redis/common/exception/ErrorCode.java @@ -0,0 +1,38 @@ +package project.redis.common.exception; + +import lombok.Getter; + +@Getter +public class ErrorCode { + + private final String messageId; + + public ErrorCode(String messageId) { + this.messageId = messageId; + } + + public static ErrorCode NOT_FOUND = new ErrorCode("COMMON.ERROR.NOT_FOUND"); + public static ErrorCode NOT_NULL = new ErrorCode("COMMON.ERROR.NOT_NULL"); + public static ErrorCode NOT_BLANK = new ErrorCode("COMMON.ERROR.NOT_BLANK"); + + /* SEAT */ + public static ErrorCode SEAT_REQUIRED_SERIES = new ErrorCode( + "SEAT.ERROR.REQUIRED_SERIES" + ); + public static final ErrorCode SEAT_DUPLICATED = new ErrorCode( + "SEAT.ERROR.DUPLICATED" + ); + public static final ErrorCode SEAT_EXCEED_COUNT = new ErrorCode( + "SEAT.ERROR.EXCEED_COUNT" + ); + public static final ErrorCode SEAT_ALREADY_RESERVED = new ErrorCode( + "SEAT.ERROR.ALREADY_RESERVED" + ); + + /* SCREENING */ + public static ErrorCode SCREENING_REQUIRED_LATER_NOW = new ErrorCode( + "SCREENING.ERROR.REQUIRED_LATER_NOW" + ); + + +} diff --git a/module-domain/README.md b/module-domain/README.md new file mode 100644 index 000000000..b59dad747 --- /dev/null +++ b/module-domain/README.md @@ -0,0 +1,97 @@ +## [Domain Module] + +### 책임 + +1. **상호작용하는 객체 관리** + - 영화(Screening), 영화관(Cinema), 영화 장르(Genre) 등 도메인 간의 상호작용을 관리. + - 각 도메인은 데이터베이스 Entity와 분리된 **비즈니스 중심 객체**로 구성됩니다. + +2. **도메인 로직 담당** + - 각 도메인 클래스는 비즈니스 로직을 내포하며, 객체 간의 유효성 검사 및 데이터 조작을 처리합니다. + - 예: 상영 시간이 유효한지 확인하거나 영화와 상영관 간의 연관성을 검증. + +3. **도메인 간 로직 처리 (추후 도메인 서비스 도입 예정)** + - 특정 도메인에 국한되지 않는 로직은 별도의 Domain Service로 분리하여 관리할 예정. + - 예: 영화 상영 시간표 생성 로직. + +--- + +### 구조 + +- **변경 불가능한 객체**: + - 모든 도메인 객체는 불변성을 유지하여 안정성과 일관성을 확보합니다. + - 예: `@Value`, 생성자 기반 객체 생성. + +- **Entity와의 분리**: + - 도메인 객체는 데이터베이스와의 의존성을 가지지 않으며, 기술적인 구현 세부사항을 포함하지 않습니다. + - Entity 클래스는 Infrastructure 계층에 존재하며, 도메인 클래스와 별도로 관리됩니다. + +--- + +### 주요 클래스 + +#### **`Screening`** +- 영화 상영 정보를 나타내는 도메인 클래스. +- 영화(`Movie`), 상영관(`Cinema`)과 연관. + +```java +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Screening { + UUID screeningId; + LocalDateTime screenStartTime; + LocalDateTime screenEndTime; + Movie movie; + Cinema cinema; + + public static Screening generateScreening( + UUID screeningId, + LocalDateTime screenStartTime, LocalDateTime screenEndTime, + Movie movie, Cinema cinema) { + return new Screening(screeningId, screenStartTime, screenEndTime, movie, cinema); + } +} +``` + +--- + +#### **`Movie`** +- 영화 정보를 담는 도메인 클래스. + +```java +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Movie { + UUID movieId; + String title; + RatingClassification rating; + LocalDate releaseDate; + Genre genre; + + public static Movie generateMovie( + UUID movieId, String title, + RatingClassification rating, LocalDate releaseDate, + Genre genre) { + return new Movie(movieId, title, rating, releaseDate, genre); + } +} +``` + +--- + +#### **설계 원칙** +1. **불변 객체**: + - 모든 필드는 `final`로 선언하여 객체의 상태 변경을 방지. + - 객체 생성은 정적 팩토리 메서드(`generateScreening`, `generateMovie`)를 통해 관리. + +2. **Entity와 도메인의 분리**: + - 데이터베이스와 상호작용하는 JPA Entity는 Infrastructure 계층에 존재. + - 도메인은 순수 비즈니스 로직만 포함하여 데이터베이스 구현 변경 시 영향을 최소화. + +3. **확장성**: + - 도메인 클래스에 새로운 필드나 로직을 추가할 때, 기존 로직의 영향을 최소화. + - Domain Service를 통해 도메인 간 복잡한 로직 분리 가능. + +--- diff --git a/module-domain/build.gradle b/module-domain/build.gradle new file mode 100644 index 000000000..b0912b853 --- /dev/null +++ b/module-domain/build.gradle @@ -0,0 +1,18 @@ +plugins { + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'project.redis.domain' +version = '0.0.1-SNAPSHOT' + +dependencies { + implementation project(':module-common') + + testImplementation("org.junit.jupiter:junit-jupiter-api") + testImplementation("org.junit.jupiter:junit-jupiter-params") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine") + + testImplementation("org.assertj:assertj-core") + testImplementation("org.mockito:mockito-core") + testImplementation("org.mockito:mockito-junit-jupiter") +} diff --git a/module-domain/src/main/java/project/redis/domain/cinema/Cinema.java b/module-domain/src/main/java/project/redis/domain/cinema/Cinema.java new file mode 100644 index 000000000..707e9e446 --- /dev/null +++ b/module-domain/src/main/java/project/redis/domain/cinema/Cinema.java @@ -0,0 +1,21 @@ +package project.redis.domain.cinema; + +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Value; + +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(force = true) +public class Cinema { + UUID cinemaId; + String cinemaName; + + public static Cinema generateCinema(UUID cinemaId, String cinemaName) { + return new Cinema(cinemaId, cinemaName); + } +} \ No newline at end of file diff --git a/module-domain/src/main/java/project/redis/domain/genre/Genre.java b/module-domain/src/main/java/project/redis/domain/genre/Genre.java new file mode 100644 index 000000000..614ac5c92 --- /dev/null +++ b/module-domain/src/main/java/project/redis/domain/genre/Genre.java @@ -0,0 +1,21 @@ +package project.redis.domain.genre; + +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Value; + +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(force = true) +public class Genre { + UUID genreId; + String genreName; + + public static Genre generateGenre(UUID genreId, String genreName) { + return new Genre(genreId, genreName); + } +} diff --git a/module-domain/src/main/java/project/redis/domain/movie/Movie.java b/module-domain/src/main/java/project/redis/domain/movie/Movie.java new file mode 100644 index 000000000..b13a23749 --- /dev/null +++ b/module-domain/src/main/java/project/redis/domain/movie/Movie.java @@ -0,0 +1,33 @@ +package project.redis.domain.movie; + +import java.time.LocalDate; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Value; +import project.redis.domain.genre.Genre; + +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(force = true) +public class Movie { + UUID movieId; + String title; + RatingClassification rating; + LocalDate releaseDate; + String thumbnailUrl; + int runningMinTime; + //TODO: 도메인에서 최소정보로 id 데이터만 가지고 있게되니, 결과적으로 디비를 한번 더 찔러야하는 상황이 생긴다... + Genre genre; + + public static Movie generateMovie( + UUID id, String title, + RatingClassification rating, LocalDate releaseDate, + String thumbnailUrl, int runningMinTime, Genre genre + ) { + return new Movie(id, title, rating, releaseDate, thumbnailUrl, runningMinTime, genre); + } +} diff --git a/module-domain/src/main/java/project/redis/domain/movie/RatingClassification.java b/module-domain/src/main/java/project/redis/domain/movie/RatingClassification.java new file mode 100644 index 000000000..daed4e418 --- /dev/null +++ b/module-domain/src/main/java/project/redis/domain/movie/RatingClassification.java @@ -0,0 +1,18 @@ +package project.redis.domain.movie; + +import lombok.Getter; + +@Getter +public enum RatingClassification { + ALL("전체관람가"), + TWELVE("12세 이상 관람가"), + FIFTEEN("15세 이상 관람가"), + NINETEEN("19세 이상 관림가"), + RESTRICT("제한상영가"); + + private final String description; + + RatingClassification(String description) { + this.description = description; + } +} diff --git a/module-domain/src/main/java/project/redis/domain/reservation/Reservation.java b/module-domain/src/main/java/project/redis/domain/reservation/Reservation.java new file mode 100644 index 000000000..aa27effe7 --- /dev/null +++ b/module-domain/src/main/java/project/redis/domain/reservation/Reservation.java @@ -0,0 +1,45 @@ +package project.redis.domain.reservation; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Value; +import project.redis.domain.screening.Screening; +import project.redis.domain.seat.Seat; + +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(force = true) +public class Reservation { + UUID reservationId; + LocalDateTime reservationTime; + String username; + Screening screening; + List seats; + + + public static Reservation generateReservation( + UUID reservationId, LocalDateTime reservationTime, + String username, + Screening screening, + List seats) { + return new Reservation(reservationId, reservationTime, username, screening, seats); + } + + public boolean isSeatAvailable(Seat seat) { + return !seats.contains(seat); + } + + public void addSeats(List newSeats) { + if (seats.size() + newSeats.size() > 5) { + throw new IllegalArgumentException("5개 이상 예약할 수 없습니다"); + } + seats.addAll(newSeats); + } + +} diff --git a/module-domain/src/main/java/project/redis/domain/reservation/ReservationSeat.java b/module-domain/src/main/java/project/redis/domain/reservation/ReservationSeat.java new file mode 100644 index 000000000..2f94e662d --- /dev/null +++ b/module-domain/src/main/java/project/redis/domain/reservation/ReservationSeat.java @@ -0,0 +1,24 @@ +package project.redis.domain.reservation; + + +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Value; + +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(force = true) +public class ReservationSeat { + UUID reservationSeatId; + UUID reservationId; + UUID seatId; + + public static ReservationSeat generateReservationSeat( + UUID reservationSeatId, UUID reservationId, UUID seatId) { + return new ReservationSeat(reservationSeatId, reservationId, seatId); + } +} diff --git a/module-domain/src/main/java/project/redis/domain/screening/Screening.java b/module-domain/src/main/java/project/redis/domain/screening/Screening.java new file mode 100644 index 000000000..ec4687beb --- /dev/null +++ b/module-domain/src/main/java/project/redis/domain/screening/Screening.java @@ -0,0 +1,41 @@ +package project.redis.domain.screening; + +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Value; +import project.redis.domain.cinema.Cinema; +import project.redis.domain.movie.Movie; + +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +@NoArgsConstructor(force = true) +public class Screening { + UUID screeningId; + LocalDateTime screenStartTime; + LocalDateTime screenEndTime; + Movie movie; + Cinema cinema; + + public static Screening generateScreening( + UUID screeningId, + LocalDateTime screenStartTime, LocalDateTime screenEndTime, + Movie movie, Cinema cinema) { + assert screeningId != null; + assert screenStartTime != null; + assert screenEndTime != null; + assert movie != null; + assert cinema != null; + + return new Screening(screeningId, screenStartTime, screenEndTime, movie, cinema); + } + + public boolean isLaterScreening() { + assert screenStartTime != null; + return screenStartTime.isAfter(LocalDateTime.now()); + } +} diff --git a/module-domain/src/main/java/project/redis/domain/seat/Seat.java b/module-domain/src/main/java/project/redis/domain/seat/Seat.java new file mode 100644 index 000000000..f8db10011 --- /dev/null +++ b/module-domain/src/main/java/project/redis/domain/seat/Seat.java @@ -0,0 +1,89 @@ +package project.redis.domain.seat; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Value; +import project.redis.common.exception.DataInvalidException; +import project.redis.common.exception.ErrorCode; +import project.redis.domain.cinema.Cinema; + +@Getter +@Value +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class Seat { + UUID seatId; + String seatNumber; + Cinema cinema; + + public static Seat generateSeat(UUID seatId, String seatNumber, Cinema cinema) { + return new Seat(seatId, seatNumber, cinema); + } + + public char getColumn() { + return this.seatNumber.charAt(0); + } + + public char getRow() { + return this.seatNumber.charAt(1); + } + + public static boolean isAvailable(List originSeats, List targetSeats) { + List originSeatsIds = originSeats.stream().map(Seat::getSeatId).toList(); + + int sameSeatCount = originSeatsIds.stream() + .filter(seatId -> targetSeats.stream() + .anyMatch(seat -> seat.getSeatId() == seatId)) + .toList().size(); + + if (sameSeatCount > 0) { + throw new DataInvalidException(ErrorCode.SEAT_DUPLICATED); + } + + if (originSeats.size() == 5 || originSeats.size() + targetSeats.size() > 5) { + throw new DataInvalidException(ErrorCode.SEAT_EXCEED_COUNT, 5); + } + + List seats = new ArrayList<>(); + seats.addAll(originSeats); + seats.addAll(targetSeats); + + return isSeries(seats); + } + + public static boolean isSeries(List seats) { + if (seats == null || seats.isEmpty()) { + return false; + } + + char column = seats.getFirst().getColumn(); + + boolean isSameColumn = seats.stream() + .allMatch(seat -> seat.getColumn() == column); + + if (!isSameColumn) { + throw new DataInvalidException(ErrorCode.SEAT_REQUIRED_SERIES); + } + + List rows = seats.stream() + .map(Seat::getRow) + .map(String::valueOf) + .map(Integer::valueOf) + .sorted() + .toList(); + + for (int i = 0; i < rows.size() - 1; i++) { + if (i == rows.size() - 1) { + continue; + } + if (rows.get(i + 1) - rows.get(i) != 1) { + throw new DataInvalidException(ErrorCode.SEAT_REQUIRED_SERIES); + } + } + + return true; + } +} diff --git a/module-domain/src/test/java/project/redis/domain/cinema/CinemaTest.java b/module-domain/src/test/java/project/redis/domain/cinema/CinemaTest.java new file mode 100644 index 000000000..d10dd5c04 --- /dev/null +++ b/module-domain/src/test/java/project/redis/domain/cinema/CinemaTest.java @@ -0,0 +1,37 @@ +package project.redis.domain.cinema; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class CinemaTest { + + @Test + void testDeserialize() throws JsonProcessingException { + ObjectMapper objectMapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .activateDefaultTyping( + LaissezFaireSubTypeValidator.instance, + ObjectMapper.DefaultTyping.NON_FINAL, + JsonTypeInfo.As.PROPERTY + ); + + Cinema cinema = Cinema.generateCinema(UUID.randomUUID(), "cinema"); + String json = objectMapper.writeValueAsString(cinema); + Cinema result = objectMapper.readValue(json, Cinema.class); + + System.out.println("json = " + json); + assertThat(result).isEqualTo(cinema); + + } +} \ No newline at end of file diff --git a/module-domain/src/test/java/project/redis/domain/screening/ScreeningTest.java b/module-domain/src/test/java/project/redis/domain/screening/ScreeningTest.java new file mode 100644 index 000000000..cc22f8f46 --- /dev/null +++ b/module-domain/src/test/java/project/redis/domain/screening/ScreeningTest.java @@ -0,0 +1,47 @@ +package project.redis.domain.screening; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import project.redis.domain.cinema.Cinema; +import project.redis.domain.movie.Movie; + +class ScreeningTest { + + + @Test + void testGenerateScreening() { + + Movie movieMock = Mockito.mock(Movie.class); + Cinema cinemaMock = Mockito.mock(Cinema.class); + UUID screeningId = UUID.randomUUID(); + LocalDateTime start = LocalDateTime.now(); + LocalDateTime end = start.plusMinutes(100); + + Screening result = Screening.generateScreening(screeningId, start, end, movieMock, cinemaMock); + + assertThat(result.getScreeningId()).isEqualTo(screeningId); + assertThat(result.getScreenStartTime()).isEqualTo(start); + assertThat(result.getScreenEndTime()).isEqualTo(end); + assertThat(result.getMovie()).isEqualTo(movieMock); + assertThat(result.getCinema()).isEqualTo(cinemaMock); + } + + @Test + void testGenerateScreeningException() { + + Movie movieMock = Mockito.mock(Movie.class); + UUID screeningId = UUID.randomUUID(); + LocalDateTime start = LocalDateTime.now(); + LocalDateTime end = start.plusMinutes(100); + + Assertions.assertThrows( + AssertionError.class, + ()-> Screening.generateScreening(screeningId, start, end, movieMock, null)); + } + +} \ No newline at end of file diff --git a/module-domain/src/test/java/project/redis/domain/seat/SeatTest.java b/module-domain/src/test/java/project/redis/domain/seat/SeatTest.java new file mode 100644 index 000000000..7a7e572a1 --- /dev/null +++ b/module-domain/src/test/java/project/redis/domain/seat/SeatTest.java @@ -0,0 +1,60 @@ +package project.redis.domain.seat; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import project.redis.domain.cinema.Cinema; + +class SeatTest { + + @Test + void testIsSeries() { + UUID cinemaId = UUID.randomUUID(); + Cinema cinema = Cinema.generateCinema(cinemaId, "Test Cinema"); + + Seat seat1 = Seat.generateSeat(UUID.randomUUID(), "A1", cinema); + Seat seat2 = Seat.generateSeat(UUID.randomUUID(), "A2", cinema); + Seat seat3 = Seat.generateSeat(UUID.randomUUID(), "A3", cinema); + + List seats = List.of(seat1, seat2, seat3); + + boolean result = Seat.isSeries(seats); + + assertThat(result).isTrue(); + } + + @Test + void testIsSeriesNoEqualsColumn() { + UUID cinemaId = UUID.randomUUID(); + Cinema cinema = Cinema.generateCinema(cinemaId, "Test Cinema"); + + Seat seat1 = Seat.generateSeat(UUID.randomUUID(), "A1", cinema); + Seat seat2 = Seat.generateSeat(UUID.randomUUID(), "A2", cinema); + Seat seat3 = Seat.generateSeat(UUID.randomUUID(), "B3", cinema); + + List seats = List.of(seat1, seat2, seat3); + + boolean result = Seat.isSeries(seats); + + assertThat(result).isFalse(); + } + + + @Test + void testIsSeriesNoSeriesRow() { + UUID cinemaId = UUID.randomUUID(); + Cinema cinema = Cinema.generateCinema(cinemaId, "Test Cinema"); + + Seat seat1 = Seat.generateSeat(UUID.randomUUID(), "A1", cinema); + Seat seat2 = Seat.generateSeat(UUID.randomUUID(), "A2", cinema); + Seat seat3 = Seat.generateSeat(UUID.randomUUID(), "A4", cinema); + + List seats = List.of(seat1, seat2, seat3); + + boolean result = Seat.isSeries(seats); + + assertThat(result).isFalse(); + } +} \ No newline at end of file diff --git a/module-infrastructure/README.md b/module-infrastructure/README.md new file mode 100644 index 000000000..036ae4f51 --- /dev/null +++ b/module-infrastructure/README.md @@ -0,0 +1,106 @@ +## [Infrastructure Module] + +### 책임 + +1. **외부 통신 및 기술 로직 담당** + - 데이터베이스, 외부 API, 메시지 큐 등 외부 시스템과의 통신을 처리. + - JPA, Flyway 등 구체적인 기술을 활용하여 데이터를 저장하고 조회. + +2. **Application 계층의 외부 의존성 제거** + - Domain 객체를 활용하여 외부 기술의 의존성을 철저히 제거. + - Application 계층은 Infrastructure 계층의 존재를 알지 못하며, Port-Adapter 패턴을 통해 간접적으로 의존. + +3. **데이터 변환 및 매핑** + - JPA Entity를 Domain 객체로 변환하여 Application 계층에 전달. + - Domain 객체에서 필요한 정보만 추출하여 비즈니스 로직에 활용 가능. + +--- + +### 구조 + +- **Port-Adapter 패턴 적용**: + - Application 계층에서 정의한 `ScreeningQueryPort` 인터페이스를 구현. + - 실제 기술 스택(JPA)을 사용하여 데이터를 처리. + +- **Domain과의 연결**: + - Infrastructure 계층에서 Domain 객체를 참조하여 데이터를 전달. + - 데이터베이스 Entity(`ScreeningJpaEntity`)를 Domain 객체(`Screening`)로 매핑. + +--- + +### 주요 클래스 + +#### **`ScreeningQueryAdapter`** +- Application 계층에서 정의한 `ScreeningQueryPort` 인터페이스를 구현. +- JPA를 사용하여 데이터를 조회하고, Domain 객체로 변환하여 반환. + +```java +@Transactional(readOnly = true) +@Component +@RequiredArgsConstructor +public class ScreeningQueryAdapter implements ScreeningQueryPort { + + private final ScreeningJpaRepository screeningJpaRepository; + + @Override + public List getScreenings(int maxScreeningDay) { + + LocalDate maxScreeningDate = LocalDate.now().plusDays(maxScreeningDay); + + List screenings + = screeningJpaRepository.findAllOrderByReleaseDescAndScreenStartTimeAsc(maxScreeningDate); + + return screenings.stream() + .map(ScreeningInfraMapper::toScreening) + .toList(); + } +} +``` + +--- + +#### **`ScreeningInfraMapper`** +- Entity와 Domain 객체 간의 변환을 처리. + +```java +public class ScreeningInfraMapper { + + public static Screening toScreening(ScreeningJpaEntity entity) { + return Screening.generateScreening( + entity.getScreeningId(), + entity.getScreeningStartTime(), + entity.getScreeningEndTime(), + MovieInfraMapper.toMovie(entity.getMovie()), + CinemaInfraMapper.toCinema(entity.getCinema()) + ); + } + +} +``` + +--- + +### 설계 원칙 + +1. **Port-Adapter 패턴 준수**: + - Port(`ScreeningQueryPort`)를 Application 계층에서 정의하고, Adapter(`ScreeningQueryAdapter`)를 통해 구현. + +2. **기술과 비즈니스의 분리**: + - JPA, Flyway와 같은 기술적인 구현은 Infrastructure 계층에서만 관리. + - Application 계층은 오직 Domain 객체만 활용. + +3. **확장성**: + - 데이터베이스가 변경되거나 다른 기술 스택(NoSQL, 외부 API 등)이 추가되더라도 Port-Adapter 패턴을 통해 쉽게 확장 가능. + +4. **Domain 중심 설계**: + - 데이터를 Domain 객체로 변환하여 Application 계층에 전달. + - 비즈니스 로직은 Application 계층에서 수행. + +--- + +### 장점 +- **유지보수성**: 외부 기술 변경(JPA -> 다른 ORM) 시에도 Application 계층은 수정이 필요 없음. +- **확장성**: 새로운 데이터 소스나 외부 API 추가 시 Port와 Adapter만 추가하면 됨. +- **독립성**: 비즈니스 로직과 기술 로직을 철저히 분리하여 각 계층의 역할을 명확히 함. + +--- diff --git a/module-infrastructure/build.gradle b/module-infrastructure/build.gradle new file mode 100644 index 000000000..cbdadcb34 --- /dev/null +++ b/module-infrastructure/build.gradle @@ -0,0 +1,73 @@ +plugins { + id 'org.springframework.boot' version '3.4.1' +} + +jar { + enabled = true +} + +bootJar { + enabled = false +} + +bootRun { + enabled = false +} + +group = 'project.redis.infrastructure' +version = '0.0.1-SNAPSHOT' + + +dependencies { + implementation project(':module-domain') + implementation project(':module-common') + implementation project(':module-application') + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8' + runtimeOnly 'com.mysql:mysql-connector-j' + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-mysql' + + implementation 'com.querydsl:querydsl-apt:5.0.0' + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + implementation 'com.querydsl:querydsl-core:5.0.0' + + implementation 'org.springframework.retry:spring-retry' + implementation 'org.springframework:spring-aspects' + + // 테스트 컨테이너 + testImplementation 'org.testcontainers:testcontainers' + testImplementation 'org.testcontainers:junit-jupiter' + testImplementation 'org.testcontainers:mysql' + testImplementation 'org.testcontainers:redis' + + // redisson + implementation 'org.redisson:redisson-spring-boot-starter:3.44.0' + + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +def generatedQueryDSLDir = 'src/main/generated' +println(generatedQueryDSLDir) + +sourceSets { + main { + java { + srcDirs += [generatedQueryDSLDir] + } + } +} + +tasks.withType(JavaCompile).configureEach { + options.generatedSourceOutputDirectory.set(file(generatedQueryDSLDir)) +} + +clean { + delete file(generatedQueryDSLDir) +} diff --git a/module-infrastructure/src/main/generated/project/redis/infrastructure/cinema/entity/QCinemaJpaEntity.java b/module-infrastructure/src/main/generated/project/redis/infrastructure/cinema/entity/QCinemaJpaEntity.java new file mode 100644 index 000000000..fc4103754 --- /dev/null +++ b/module-infrastructure/src/main/generated/project/redis/infrastructure/cinema/entity/QCinemaJpaEntity.java @@ -0,0 +1,53 @@ +package project.redis.infrastructure.cinema.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; + + +/** + * QCinemaJpaEntity is a Querydsl query type for CinemaJpaEntity + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QCinemaJpaEntity extends EntityPathBase { + + private static final long serialVersionUID = -1437267300L; + + public static final QCinemaJpaEntity cinemaJpaEntity = new QCinemaJpaEntity("cinemaJpaEntity"); + + public final project.redis.infrastructure.common.entity.QBaseJpaEntity _super = new project.redis.infrastructure.common.entity.QBaseJpaEntity(this); + + public final StringPath cinemaName = createString("cinemaName"); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final ComparablePath createdBy = _super.createdBy; + + public final ComparablePath id = createComparable("id", java.util.UUID.class); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + //inherited + public final ComparablePath updatedBy = _super.updatedBy; + + public QCinemaJpaEntity(String variable) { + super(CinemaJpaEntity.class, forVariable(variable)); + } + + public QCinemaJpaEntity(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QCinemaJpaEntity(PathMetadata metadata) { + super(CinemaJpaEntity.class, metadata); + } + +} + diff --git a/module-infrastructure/src/main/generated/project/redis/infrastructure/common/entity/QBaseJpaEntity.java b/module-infrastructure/src/main/generated/project/redis/infrastructure/common/entity/QBaseJpaEntity.java new file mode 100644 index 000000000..9f6ed0429 --- /dev/null +++ b/module-infrastructure/src/main/generated/project/redis/infrastructure/common/entity/QBaseJpaEntity.java @@ -0,0 +1,43 @@ +package project.redis.infrastructure.common.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; + + +/** + * QBaseJpaEntity is a Querydsl query type for BaseJpaEntity + */ +@Generated("com.querydsl.codegen.DefaultSupertypeSerializer") +public class QBaseJpaEntity extends EntityPathBase { + + private static final long serialVersionUID = 608046018L; + + public static final QBaseJpaEntity baseJpaEntity = new QBaseJpaEntity("baseJpaEntity"); + + public final DateTimePath createdAt = createDateTime("createdAt", java.time.LocalDateTime.class); + + public final ComparablePath createdBy = createComparable("createdBy", java.util.UUID.class); + + public final DateTimePath updatedAt = createDateTime("updatedAt", java.time.LocalDateTime.class); + + public final ComparablePath updatedBy = createComparable("updatedBy", java.util.UUID.class); + + public QBaseJpaEntity(String variable) { + super(BaseJpaEntity.class, forVariable(variable)); + } + + public QBaseJpaEntity(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QBaseJpaEntity(PathMetadata metadata) { + super(BaseJpaEntity.class, metadata); + } + +} + diff --git a/module-infrastructure/src/main/generated/project/redis/infrastructure/genre/entity/QGenreJpaEntity.java b/module-infrastructure/src/main/generated/project/redis/infrastructure/genre/entity/QGenreJpaEntity.java new file mode 100644 index 000000000..58b2154cd --- /dev/null +++ b/module-infrastructure/src/main/generated/project/redis/infrastructure/genre/entity/QGenreJpaEntity.java @@ -0,0 +1,53 @@ +package project.redis.infrastructure.genre.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; + + +/** + * QGenreJpaEntity is a Querydsl query type for GenreJpaEntity + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QGenreJpaEntity extends EntityPathBase { + + private static final long serialVersionUID = 515269496L; + + public static final QGenreJpaEntity genreJpaEntity = new QGenreJpaEntity("genreJpaEntity"); + + public final project.redis.infrastructure.common.entity.QBaseJpaEntity _super = new project.redis.infrastructure.common.entity.QBaseJpaEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final ComparablePath createdBy = _super.createdBy; + + public final StringPath genreName = createString("genreName"); + + public final ComparablePath id = createComparable("id", java.util.UUID.class); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + //inherited + public final ComparablePath updatedBy = _super.updatedBy; + + public QGenreJpaEntity(String variable) { + super(GenreJpaEntity.class, forVariable(variable)); + } + + public QGenreJpaEntity(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QGenreJpaEntity(PathMetadata metadata) { + super(GenreJpaEntity.class, metadata); + } + +} + diff --git a/module-infrastructure/src/main/generated/project/redis/infrastructure/movie/entity/QMovieJpaEntity.java b/module-infrastructure/src/main/generated/project/redis/infrastructure/movie/entity/QMovieJpaEntity.java new file mode 100644 index 000000000..3076ff3c5 --- /dev/null +++ b/module-infrastructure/src/main/generated/project/redis/infrastructure/movie/entity/QMovieJpaEntity.java @@ -0,0 +1,75 @@ +package project.redis.infrastructure.movie.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QMovieJpaEntity is a Querydsl query type for MovieJpaEntity + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QMovieJpaEntity extends EntityPathBase { + + private static final long serialVersionUID = 1818379864L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QMovieJpaEntity movieJpaEntity = new QMovieJpaEntity("movieJpaEntity"); + + public final project.redis.infrastructure.common.entity.QBaseJpaEntity _super = new project.redis.infrastructure.common.entity.QBaseJpaEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final ComparablePath createdBy = _super.createdBy; + + public final project.redis.infrastructure.genre.entity.QGenreJpaEntity genre; + + public final ComparablePath id = createComparable("id", java.util.UUID.class); + + public final EnumPath rating = createEnum("rating", project.redis.domain.movie.RatingClassification.class); + + public final DatePath releaseDate = createDate("releaseDate", java.time.LocalDate.class); + + public final NumberPath runningMinTime = createNumber("runningMinTime", Integer.class); + + public final StringPath thumbnailUrl = createString("thumbnailUrl"); + + public final StringPath title = createString("title"); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + //inherited + public final ComparablePath updatedBy = _super.updatedBy; + + public QMovieJpaEntity(String variable) { + this(MovieJpaEntity.class, forVariable(variable), INITS); + } + + public QMovieJpaEntity(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QMovieJpaEntity(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QMovieJpaEntity(PathMetadata metadata, PathInits inits) { + this(MovieJpaEntity.class, metadata, inits); + } + + public QMovieJpaEntity(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.genre = inits.isInitialized("genre") ? new project.redis.infrastructure.genre.entity.QGenreJpaEntity(forProperty("genre")) : null; + } + +} + diff --git a/module-infrastructure/src/main/generated/project/redis/infrastructure/reservation/entity/QReservationJpaEntity.java b/module-infrastructure/src/main/generated/project/redis/infrastructure/reservation/entity/QReservationJpaEntity.java new file mode 100644 index 000000000..573a2db43 --- /dev/null +++ b/module-infrastructure/src/main/generated/project/redis/infrastructure/reservation/entity/QReservationJpaEntity.java @@ -0,0 +1,69 @@ +package project.redis.infrastructure.reservation.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QReservationJpaEntity is a Querydsl query type for ReservationJpaEntity + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QReservationJpaEntity extends EntityPathBase { + + private static final long serialVersionUID = 635228824L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QReservationJpaEntity reservationJpaEntity = new QReservationJpaEntity("reservationJpaEntity"); + + public final project.redis.infrastructure.common.entity.QBaseJpaEntity _super = new project.redis.infrastructure.common.entity.QBaseJpaEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final ComparablePath createdBy = _super.createdBy; + + public final ComparablePath id = createComparable("id", java.util.UUID.class); + + public final DateTimePath reservationTime = createDateTime("reservationTime", java.time.LocalDateTime.class); + + public final project.redis.infrastructure.screening.entity.QScreeningJpaEntity screening; + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + //inherited + public final ComparablePath updatedBy = _super.updatedBy; + + public final StringPath username = createString("username"); + + public QReservationJpaEntity(String variable) { + this(ReservationJpaEntity.class, forVariable(variable), INITS); + } + + public QReservationJpaEntity(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QReservationJpaEntity(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QReservationJpaEntity(PathMetadata metadata, PathInits inits) { + this(ReservationJpaEntity.class, metadata, inits); + } + + public QReservationJpaEntity(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.screening = inits.isInitialized("screening") ? new project.redis.infrastructure.screening.entity.QScreeningJpaEntity(forProperty("screening"), inits.get("screening")) : null; + } + +} + diff --git a/module-infrastructure/src/main/generated/project/redis/infrastructure/reservation/entity/QReservationSeatJpaEntity.java b/module-infrastructure/src/main/generated/project/redis/infrastructure/reservation/entity/QReservationSeatJpaEntity.java new file mode 100644 index 000000000..4933bfaab --- /dev/null +++ b/module-infrastructure/src/main/generated/project/redis/infrastructure/reservation/entity/QReservationSeatJpaEntity.java @@ -0,0 +1,71 @@ +package project.redis.infrastructure.reservation.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QReservationSeatJpaEntity is a Querydsl query type for ReservationSeatJpaEntity + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QReservationSeatJpaEntity extends EntityPathBase { + + private static final long serialVersionUID = -435679981L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QReservationSeatJpaEntity reservationSeatJpaEntity = new QReservationSeatJpaEntity("reservationSeatJpaEntity"); + + public final project.redis.infrastructure.common.entity.QBaseJpaEntity _super = new project.redis.infrastructure.common.entity.QBaseJpaEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final ComparablePath createdBy = _super.createdBy; + + public final ComparablePath id = createComparable("id", java.util.UUID.class); + + public final QReservationJpaEntity reservation; + + public final project.redis.infrastructure.screening.entity.QScreeningJpaEntity screening; + + public final project.redis.infrastructure.seat.entity.QSeatJpaEntity seat; + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + //inherited + public final ComparablePath updatedBy = _super.updatedBy; + + public QReservationSeatJpaEntity(String variable) { + this(ReservationSeatJpaEntity.class, forVariable(variable), INITS); + } + + public QReservationSeatJpaEntity(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QReservationSeatJpaEntity(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QReservationSeatJpaEntity(PathMetadata metadata, PathInits inits) { + this(ReservationSeatJpaEntity.class, metadata, inits); + } + + public QReservationSeatJpaEntity(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.reservation = inits.isInitialized("reservation") ? new QReservationJpaEntity(forProperty("reservation"), inits.get("reservation")) : null; + this.screening = inits.isInitialized("screening") ? new project.redis.infrastructure.screening.entity.QScreeningJpaEntity(forProperty("screening"), inits.get("screening")) : null; + this.seat = inits.isInitialized("seat") ? new project.redis.infrastructure.seat.entity.QSeatJpaEntity(forProperty("seat"), inits.get("seat")) : null; + } + +} + diff --git a/module-infrastructure/src/main/generated/project/redis/infrastructure/screening/entity/QScreeningJpaEntity.java b/module-infrastructure/src/main/generated/project/redis/infrastructure/screening/entity/QScreeningJpaEntity.java new file mode 100644 index 000000000..c23848ace --- /dev/null +++ b/module-infrastructure/src/main/generated/project/redis/infrastructure/screening/entity/QScreeningJpaEntity.java @@ -0,0 +1,72 @@ +package project.redis.infrastructure.screening.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QScreeningJpaEntity is a Querydsl query type for ScreeningJpaEntity + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QScreeningJpaEntity extends EntityPathBase { + + private static final long serialVersionUID = -456835560L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QScreeningJpaEntity screeningJpaEntity = new QScreeningJpaEntity("screeningJpaEntity"); + + public final project.redis.infrastructure.common.entity.QBaseJpaEntity _super = new project.redis.infrastructure.common.entity.QBaseJpaEntity(this); + + public final project.redis.infrastructure.cinema.entity.QCinemaJpaEntity cinema; + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final ComparablePath createdBy = _super.createdBy; + + public final ComparablePath id = createComparable("id", java.util.UUID.class); + + public final project.redis.infrastructure.movie.entity.QMovieJpaEntity movie; + + public final DateTimePath screeningEndTime = createDateTime("screeningEndTime", java.time.LocalDateTime.class); + + public final DateTimePath screeningStartTime = createDateTime("screeningStartTime", java.time.LocalDateTime.class); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + //inherited + public final ComparablePath updatedBy = _super.updatedBy; + + public QScreeningJpaEntity(String variable) { + this(ScreeningJpaEntity.class, forVariable(variable), INITS); + } + + public QScreeningJpaEntity(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QScreeningJpaEntity(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QScreeningJpaEntity(PathMetadata metadata, PathInits inits) { + this(ScreeningJpaEntity.class, metadata, inits); + } + + public QScreeningJpaEntity(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.cinema = inits.isInitialized("cinema") ? new project.redis.infrastructure.cinema.entity.QCinemaJpaEntity(forProperty("cinema")) : null; + this.movie = inits.isInitialized("movie") ? new project.redis.infrastructure.movie.entity.QMovieJpaEntity(forProperty("movie"), inits.get("movie")) : null; + } + +} + diff --git a/module-infrastructure/src/main/generated/project/redis/infrastructure/seat/entity/QSeatJpaEntity.java b/module-infrastructure/src/main/generated/project/redis/infrastructure/seat/entity/QSeatJpaEntity.java new file mode 100644 index 000000000..386aba085 --- /dev/null +++ b/module-infrastructure/src/main/generated/project/redis/infrastructure/seat/entity/QSeatJpaEntity.java @@ -0,0 +1,67 @@ +package project.redis.infrastructure.seat.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QSeatJpaEntity is a Querydsl query type for SeatJpaEntity + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QSeatJpaEntity extends EntityPathBase { + + private static final long serialVersionUID = -344098188L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QSeatJpaEntity seatJpaEntity = new QSeatJpaEntity("seatJpaEntity"); + + public final project.redis.infrastructure.common.entity.QBaseJpaEntity _super = new project.redis.infrastructure.common.entity.QBaseJpaEntity(this); + + public final project.redis.infrastructure.cinema.entity.QCinemaJpaEntity cinema; + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final ComparablePath createdBy = _super.createdBy; + + public final ComparablePath id = createComparable("id", java.util.UUID.class); + + public final StringPath seatNumber = createString("seatNumber"); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + //inherited + public final ComparablePath updatedBy = _super.updatedBy; + + public QSeatJpaEntity(String variable) { + this(SeatJpaEntity.class, forVariable(variable), INITS); + } + + public QSeatJpaEntity(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QSeatJpaEntity(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QSeatJpaEntity(PathMetadata metadata, PathInits inits) { + this(SeatJpaEntity.class, metadata, inits); + } + + public QSeatJpaEntity(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.cinema = inits.isInitialized("cinema") ? new project.redis.infrastructure.cinema.entity.QCinemaJpaEntity(forProperty("cinema")) : null; + } + +} + diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/ScreeningDataInit.java b/module-infrastructure/src/main/java/project/redis/infrastructure/ScreeningDataInit.java new file mode 100644 index 000000000..3034ad1c5 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/ScreeningDataInit.java @@ -0,0 +1,70 @@ +//package project.redis.infrastructure; +// +//import java.time.LocalDate; +//import java.time.LocalDateTime; +//import java.time.temporal.ChronoUnit; +//import java.util.List; +//import java.util.Random; +//import java.util.concurrent.ThreadLocalRandom; +//import java.util.stream.Stream; +//import lombok.RequiredArgsConstructor; +//import org.springframework.boot.CommandLineRunner; +//import org.springframework.stereotype.Component; +//import project.redis.infrastructure.cinema.entity.CinemaJpaEntity; +//import project.redis.infrastructure.cinema.repository.CinemaJpaRepository; +//import project.redis.infrastructure.movie.entity.MovieJpaEntity; +//import project.redis.infrastructure.movie.repository.MovieJpaRepository; +//import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; +//import project.redis.infrastructure.screening.repository.ScreeningJpaRepository; +// +//@Component +//@RequiredArgsConstructor +//public class ScreeningDataInit implements CommandLineRunner { +// +// private final ScreeningJpaRepository screeningJpaRepository; +// private final MovieJpaRepository movieJpaRepository; +// private final CinemaJpaRepository cinemaJpaRepository; +// +// private static final Random RANDOM = new Random(); +// +// @Override +// public void run(String... args) throws Exception { +// List movies = movieJpaRepository.findAll(); +// List cinemas = cinemaJpaRepository.findAll(); +// +// Stream.iterate(0, i -> i + 1) +// .limit(500) +// .parallel() +// .map(index -> { +// +// MovieJpaEntity movieJpaEntity = movies.get(RANDOM.nextInt(movies.size())); +// CinemaJpaEntity cinemaJpaEntity = cinemas.get(RANDOM.nextInt(cinemas.size())); +// +// LocalDateTime startTime = generateRandomStartTime(); +// LocalDateTime endTime = startTime.plusMinutes(movieJpaEntity.getRunningMinTime()); +// +// return ScreeningJpaEntity.builder() +// .screeningStartTime(startTime) +// .screeningEndTime(endTime) +// .movie(movieJpaEntity) +// .cinema(cinemaJpaEntity) +// .build(); +// +// }) +// .forEach(screeningJpaRepository::save); +// +// } +// +// public LocalDateTime generateRandomStartTime() { +// LocalDate today = LocalDate.now(); +// LocalDate startDate = today.plusDays(1); +// LocalDate endDate = today.plusDays(20); +// +// long randomDays = ThreadLocalRandom.current() +// .nextLong(ChronoUnit.DAYS.between(startDate, endDate) + 1); +// +// return startDate +// .plusDays(randomDays) +// .atTime(new Random().nextInt(18), new Random().nextInt(60)); +// } +//} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/adapter/CinemaCommandAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/adapter/CinemaCommandAdapter.java new file mode 100644 index 000000000..4af938b32 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/adapter/CinemaCommandAdapter.java @@ -0,0 +1,32 @@ +package project.redis.infrastructure.cinema.adapter; + + +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import project.redis.application.cinema.port.outbound.CinemaCommandPort; +import project.redis.infrastructure.cinema.entity.CinemaJpaEntity; +import project.redis.infrastructure.cinema.repository.CinemaJpaRepository; + +@Component +@RequiredArgsConstructor +public class CinemaCommandAdapter implements CinemaCommandPort { + + private final CinemaJpaRepository cinemaJpaRepository; + + @Override + public void createCinema(String cinemaName) throws IllegalArgumentException { + Optional cinemaOptional = cinemaJpaRepository.findByCinemaName(cinemaName); + + //TODO: 예외 처리를 담당하는 Exception, ExceptionHandler를 모아두는 모듈 필요 + if (cinemaOptional.isPresent()) { + throw new IllegalArgumentException("Cinema with name '" + cinemaName + "' already exists"); + } + + cinemaJpaRepository.save( + CinemaJpaEntity.builder() + .cinemaName(cinemaName) + .build() + ); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/adapter/CinemaQueryAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/adapter/CinemaQueryAdapter.java new file mode 100644 index 000000000..c0d9b163e --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/adapter/CinemaQueryAdapter.java @@ -0,0 +1,26 @@ +package project.redis.infrastructure.cinema.adapter; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import project.redis.application.cinema.port.outbound.CinemaQueryPort; +import project.redis.domain.cinema.Cinema; +import project.redis.infrastructure.cinema.entity.CinemaJpaEntity; +import project.redis.infrastructure.cinema.mapper.CinemaInfraMapper; +import project.redis.infrastructure.cinema.repository.CinemaJpaRepository; + + +@Component +@RequiredArgsConstructor +public class CinemaQueryAdapter implements CinemaQueryPort { + + private final CinemaJpaRepository cinemaJpaRepository; + + @Override + public List getCinemas() { + List cinemas = cinemaJpaRepository.findAll(); + return cinemas.stream() + .map(CinemaInfraMapper::toCinema) + .toList(); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/entity/CinemaJpaEntity.java b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/entity/CinemaJpaEntity.java new file mode 100644 index 000000000..dddb09469 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/entity/CinemaJpaEntity.java @@ -0,0 +1,33 @@ +package project.redis.infrastructure.cinema.entity; + + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.UuidGenerator; +import project.redis.infrastructure.common.entity.BaseJpaEntity; + +@Entity +@Builder +@Table(name = "cinema") +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CinemaJpaEntity extends BaseJpaEntity { + @Id + @GeneratedValue(generator = "uuid") + @UuidGenerator + @Column(name = "cinema_id", columnDefinition = "BINARY(16)") + private UUID id; + + @Column(nullable = false) + private String cinemaName; +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/mapper/CinemaInfraMapper.java b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/mapper/CinemaInfraMapper.java new file mode 100644 index 000000000..30b6212e3 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/mapper/CinemaInfraMapper.java @@ -0,0 +1,23 @@ +package project.redis.infrastructure.cinema.mapper; + + +import project.redis.domain.cinema.Cinema; +import project.redis.infrastructure.cinema.entity.CinemaJpaEntity; + +public class CinemaInfraMapper { + + public static Cinema toCinema(CinemaJpaEntity cinema) { + return Cinema.generateCinema( + cinema.getId(), + cinema.getCinemaName() + ); + } + + public static CinemaJpaEntity toEntity(Cinema cinema) { + return CinemaJpaEntity.builder() + .id(cinema.getCinemaId()) + .cinemaName(cinema.getCinemaName()) + .build(); + } + +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/repository/CinemaJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/repository/CinemaJpaRepository.java new file mode 100644 index 000000000..057b46452 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/cinema/repository/CinemaJpaRepository.java @@ -0,0 +1,11 @@ +package project.redis.infrastructure.cinema.repository; + +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import project.redis.infrastructure.cinema.entity.CinemaJpaEntity; + +public interface CinemaJpaRepository extends JpaRepository { + + Optional findByCinemaName(String cinemaName); +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/JpaConfig.java b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/JpaConfig.java new file mode 100644 index 000000000..9a06b066e --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/JpaConfig.java @@ -0,0 +1,26 @@ +package project.redis.infrastructure.common.config; + + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@Configuration +@EnableJpaAuditing +@EnableJpaRepositories(basePackages = { + "project.redis.infrastructure" +}) +@EntityScan(basePackages = { + "project.redis.infrastructure" +}) +public class JpaConfig { + + @Bean + public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) { + return new JPAQueryFactory(entityManager); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/LocalCacheConfig.java b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/LocalCacheConfig.java new file mode 100644 index 000000000..a695512ef --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/LocalCacheConfig.java @@ -0,0 +1,31 @@ +package project.redis.infrastructure.common.config; + +import com.github.benmanes.caffeine.cache.Caffeine; +import java.time.Duration; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@EnableCaching +@Configuration +public class LocalCacheConfig { + + @Bean + public Caffeine caffeineConfig() { + return Caffeine.newBuilder() + .expireAfterWrite(Duration.ofDays(1)) + .initialCapacity(200) + .maximumSize(500) + .recordStats(); + } + + + @Bean("localCacheManager") + public CacheManager localCacheManager() { + CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager(); + caffeineCacheManager.setCaffeine(caffeineConfig()); + return caffeineCacheManager; + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RedisConfig.java b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RedisConfig.java new file mode 100644 index 000000000..3dcb74a51 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RedisConfig.java @@ -0,0 +1,116 @@ +package project.redis.infrastructure.common.config; + + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import org.redisson.Redisson; +import org.redisson.api.RedissonClient; +import org.redisson.config.Config; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.interceptor.KeyGenerator; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.RedisSerializationContext.SerializationPair; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import project.redis.application.screening.port.outbound.ScreeningQueryFilter; + +@Configuration +@EnableCaching +public class RedisConfig { + + @Value("${redis.host}") + private String host; + + @Value("${redis.port}") + private int port; + + public final static String REDIS_SCREENING = "SCREENING"; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration standaloneConfig + = new RedisStandaloneConfiguration(host, port); + + return new LettuceConnectionFactory(standaloneConfig); + } + + @Primary + @Bean("redisCacheManager") + public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) { + + ObjectMapper objectMapper = new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .activateDefaultTyping( + LaissezFaireSubTypeValidator.instance, + ObjectMapper.DefaultTyping.NON_FINAL, + JsonTypeInfo.As.PROPERTY + ); + + + Map cacheConfigurations = new HashMap<>(); + cacheConfigurations.put(REDIS_SCREENING, RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith( + RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()) + ) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer( + new GenericJackson2JsonRedisSerializer(objectMapper) + ) + ) + .entryTtl(Duration.ofDays(1)) + ); + + return RedisCacheManager.builder(redisConnectionFactory) + .cacheDefaults( + RedisCacheConfiguration.defaultCacheConfig() + .serializeKeysWith(SerializationPair + .fromSerializer(new StringRedisSerializer())) + .serializeValuesWith( + SerializationPair.fromSerializer( + new GenericJackson2JsonRedisSerializer(objectMapper) + ) + ) + .entryTtl(Duration.ofHours(1)) + ) + .withInitialCacheConfigurations(cacheConfigurations) + .build(); + } + + @Bean("screeningKeyGenerator") + public KeyGenerator screeningKeyGenerator() { + return (target, method, params) -> { + ScreeningQueryFilter filter = (ScreeningQueryFilter) params[0]; + String movieName = filter.getMovieName() != null ? filter.getMovieName() : "ALL"; + String genreName = filter.getGenreName() != null ? filter.getGenreName() : "ALL"; + return "maxDays:" + filter.getMaxScreeningDay() + + ":movie:" + movieName + + ":genre:" + genreName; + }; + } + + @Bean + public RedissonClient redissonClient() { + Config config = new Config(); + config.useSingleServer().setAddress("redis://" + host + ":" + port); + return Redisson.create(config); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RetryConfig.java b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RetryConfig.java new file mode 100644 index 000000000..7ab284180 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/common/config/RetryConfig.java @@ -0,0 +1,28 @@ +package project.redis.infrastructure.common.config; + + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; +import org.springframework.retry.backoff.FixedBackOffPolicy; +import org.springframework.retry.policy.SimpleRetryPolicy; +import org.springframework.retry.support.RetryTemplate; + + +@Configuration +@EnableRetry +public class RetryConfig { + + @Bean("reservationSeatRetryTemplate") + public RetryTemplate reservationSeatRetryTemplate() { + RetryTemplate retryTemplate = new RetryTemplate(); + + retryTemplate.setRetryPolicy(new SimpleRetryPolicy(2)); + + FixedBackOffPolicy fixedBackOffPolicy = new FixedBackOffPolicy(); + fixedBackOffPolicy.setBackOffPeriod(50); + retryTemplate.setBackOffPolicy(fixedBackOffPolicy); + + return retryTemplate; + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/common/entity/BaseJpaEntity.java b/module-infrastructure/src/main/java/project/redis/infrastructure/common/entity/BaseJpaEntity.java new file mode 100644 index 000000000..859c2b4e8 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/common/entity/BaseJpaEntity.java @@ -0,0 +1,33 @@ +package project.redis.infrastructure.common.entity; + + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import java.util.UUID; +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; + +@Getter +@EntityListeners(AuditingEntityListener.class) +@MappedSuperclass +public abstract class BaseJpaEntity { + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; + + @CreatedBy + @Column(updatable = false, columnDefinition = "BINARY(16)") + private UUID createdBy; + + @LastModifiedBy + @Column(columnDefinition = "BINARY(16)") + private UUID updatedBy; +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/genre/adapter/GenreQueryAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/genre/adapter/GenreQueryAdapter.java new file mode 100644 index 000000000..a20c694cc --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/genre/adapter/GenreQueryAdapter.java @@ -0,0 +1,24 @@ +package project.redis.infrastructure.genre.adapter; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import project.redis.application.genre.port.outbound.GenreQueryPort; +import project.redis.domain.genre.Genre; +import project.redis.infrastructure.genre.mapper.GenreInfraMapper; +import project.redis.infrastructure.genre.repository.GenreJpaRepository; + + +@Component +@RequiredArgsConstructor +public class GenreQueryAdapter implements GenreQueryPort { + + private final GenreJpaRepository genreJpaRepository; + + @Override + public List findAllGenres() { + return genreJpaRepository.findAll().stream() + .map(GenreInfraMapper::toGenre) + .toList(); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/genre/entity/GenreJpaEntity.java b/module-infrastructure/src/main/java/project/redis/infrastructure/genre/entity/GenreJpaEntity.java new file mode 100644 index 000000000..50592b666 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/genre/entity/GenreJpaEntity.java @@ -0,0 +1,33 @@ +package project.redis.infrastructure.genre.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.UuidGenerator; +import project.redis.infrastructure.common.entity.BaseJpaEntity; + + +@Entity +@Builder +@Table(name = "genre") +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class GenreJpaEntity extends BaseJpaEntity { + @Id + @GeneratedValue(generator = "uuid") + @UuidGenerator + @Column(name = "genre_id", columnDefinition = "BINARY(16)") + private UUID id; + + @Column(nullable = false) + private String genreName; +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/genre/mapper/GenreInfraMapper.java b/module-infrastructure/src/main/java/project/redis/infrastructure/genre/mapper/GenreInfraMapper.java new file mode 100644 index 000000000..9b11178c7 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/genre/mapper/GenreInfraMapper.java @@ -0,0 +1,21 @@ +package project.redis.infrastructure.genre.mapper; + +import project.redis.domain.genre.Genre; +import project.redis.infrastructure.genre.entity.GenreJpaEntity; + +public class GenreInfraMapper { + + public static Genre toGenre(GenreJpaEntity genre) { + return Genre.generateGenre( + genre.getId(), + genre.getGenreName() + ); + } + + public static GenreJpaEntity toEntity(Genre genre) { + return GenreJpaEntity.builder() + .id(genre.getGenreId()) + .genreName(genre.getGenreName()) + .build(); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/genre/repository/GenreJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/genre/repository/GenreJpaRepository.java new file mode 100644 index 000000000..3df16f7d9 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/genre/repository/GenreJpaRepository.java @@ -0,0 +1,8 @@ +package project.redis.infrastructure.genre.repository; + +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import project.redis.infrastructure.genre.entity.GenreJpaEntity; + +public interface GenreJpaRepository extends JpaRepository { +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/movie/adapter/MovieQueryAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/adapter/MovieQueryAdapter.java new file mode 100644 index 000000000..09604168a --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/adapter/MovieQueryAdapter.java @@ -0,0 +1,26 @@ +package project.redis.infrastructure.movie.adapter; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import project.redis.domain.movie.Movie; +import project.redis.infrastructure.movie.entity.MovieJpaEntity; +import project.redis.application.movie.port.outbound.MovieQueryPort; +import project.redis.infrastructure.movie.mapper.MovieInfraMapper; +import project.redis.infrastructure.movie.repository.MovieJpaRepository; + +@Component +@RequiredArgsConstructor +public class MovieQueryAdapter implements MovieQueryPort { + + private final MovieJpaRepository movieJpaRepository; + + @Override + public List getMovies() { + List movies = movieJpaRepository.findAll(); + + return movies.stream() + .map(MovieInfraMapper::toMovie) + .toList(); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/movie/entity/MovieJpaEntity.java b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/entity/MovieJpaEntity.java new file mode 100644 index 000000000..f81b34fd1 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/entity/MovieJpaEntity.java @@ -0,0 +1,63 @@ +package project.redis.infrastructure.movie.entity; + + +import static jakarta.persistence.EnumType.STRING; + +import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; +import jakarta.persistence.Entity; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import java.time.LocalDate; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.UuidGenerator; +import project.redis.infrastructure.common.entity.BaseJpaEntity; +import project.redis.domain.movie.RatingClassification; +import project.redis.infrastructure.genre.entity.GenreJpaEntity; + +@Entity +@Builder +@Table(name = "movie") +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MovieJpaEntity extends BaseJpaEntity { + + @Id + @GeneratedValue(generator = "uuid") + @UuidGenerator + @Column(name = "movie_id", columnDefinition = "BINARY(16)") + private UUID id; + + @Column(nullable = false) + private String title; + + @Column(nullable = false) + @Enumerated(value = STRING) + private RatingClassification rating; + + @Column(nullable = false) + private LocalDate releaseDate; + + @Column(columnDefinition = "TEXT") + private String thumbnailUrl; + + @Column(nullable = false) + private int runningMinTime; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "genre_id", columnDefinition = "BINARY(16)", + foreignKey = @ForeignKey(value = ConstraintMode.NO_CONSTRAINT)) + private GenreJpaEntity genre; +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/movie/mapper/MovieInfraMapper.java b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/mapper/MovieInfraMapper.java new file mode 100644 index 000000000..570427325 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/mapper/MovieInfraMapper.java @@ -0,0 +1,34 @@ +package project.redis.infrastructure.movie.mapper; + + +import project.redis.domain.movie.Movie; +import project.redis.infrastructure.genre.mapper.GenreInfraMapper; +import project.redis.infrastructure.movie.entity.MovieJpaEntity; + +public class MovieInfraMapper { + + public static Movie toMovie(MovieJpaEntity movie) { + return Movie.generateMovie( + movie.getId(), + movie.getTitle(), + movie.getRating(), + movie.getReleaseDate(), + movie.getThumbnailUrl(), + movie.getRunningMinTime(), + GenreInfraMapper.toGenre(movie.getGenre()) + ); + } + + public static MovieJpaEntity toEntity(Movie movie) { + return MovieJpaEntity.builder() + .id(movie.getMovieId()) + .title(movie.getTitle()) + .rating(movie.getRating()) + .releaseDate(movie.getReleaseDate()) + .thumbnailUrl(movie.getThumbnailUrl()) + .runningMinTime(movie.getRunningMinTime()) + .genre(GenreInfraMapper.toEntity(movie.getGenre())) + .build(); + } + +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/movie/repository/MovieJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/repository/MovieJpaRepository.java new file mode 100644 index 000000000..3013f0d8f --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/movie/repository/MovieJpaRepository.java @@ -0,0 +1,13 @@ +package project.redis.infrastructure.movie.repository; + +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import project.redis.infrastructure.movie.entity.MovieJpaEntity; + +public interface MovieJpaRepository extends JpaRepository { + + @EntityGraph(attributePaths = {"genre"}) + List findAll(); +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapter.java new file mode 100644 index 000000000..cedd091f7 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapter.java @@ -0,0 +1,40 @@ +package project.redis.infrastructure.reservation.adapter; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import project.redis.application.reservation.port.outbound.ReservationCommandPort; +import project.redis.domain.reservation.Reservation; +import project.redis.domain.seat.Seat; +import project.redis.infrastructure.reservation.entity.ReservationJpaEntity; +import project.redis.infrastructure.reservation.entity.ReservationSeatJpaEntity; +import project.redis.infrastructure.reservation.mapper.ReservationInfraMapper; +import project.redis.infrastructure.reservation.repository.ReservationJpaRepository; +import project.redis.infrastructure.reservation.repository.ReservationSeatJpaRepository; +import project.redis.infrastructure.screening.mapper.ScreeningInfraMapper; +import project.redis.infrastructure.seat.mapper.SeatInfraMapper; + + +@Transactional +@Component +@RequiredArgsConstructor +public class ReservationCommandAdapter implements ReservationCommandPort { + + private final ReservationJpaRepository reservationJpaRepository; + private final ReservationSeatJpaRepository reservationSeatJpaRepository; + + @Override + public void reserve(Reservation reservation) { + ReservationJpaEntity savedReservation = reservationJpaRepository.save(ReservationInfraMapper.toEntity(reservation)); + + for (Seat seat : reservation.getSeats()) { + ReservationSeatJpaEntity reservationSeat = ReservationSeatJpaEntity.builder() + .reservation(savedReservation) + .screening(ScreeningInfraMapper.toEntity(reservation.getScreening())) + .seat(SeatInfraMapper.toEntity(seat)) + .build(); + + reservationSeatJpaRepository.save(reservationSeat); + } + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationLockAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationLockAdapter.java new file mode 100644 index 000000000..3baa287c7 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationLockAdapter.java @@ -0,0 +1,68 @@ +package project.redis.infrastructure.reservation.adapter; + +import java.util.List; +import java.util.concurrent.TimeUnit; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; +import project.redis.application.reservation.port.outbound.ReservationLockPort; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ReservationLockAdapter implements ReservationLockPort { + + private final RedissonClient redissonClient; + + @Override + public boolean tryLock(String lockKey, long waitTimeMils, long releaseTimeMils) { + RLock lock = redissonClient.getLock(lockKey); + try { + return lock.tryLock(waitTimeMils, releaseTimeMils, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + + @Override + public boolean tryScreeningSeatLock(List lockKeys, long waitTimeMils, long releaseTimeMils) { + RLock[] locks = lockKeys.stream() + .map(redissonClient::getLock) + .toArray(RLock[]::new); + + RLock multiLock = redissonClient.getMultiLock(locks); + + try { + log.info("Trying to acquire multi-lock for keys: {}", lockKeys); + boolean acquired = multiLock.tryLock(waitTimeMils, releaseTimeMils, TimeUnit.MILLISECONDS); + log.info("Multi-lock acquired: {}", acquired); + return acquired; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return false; + } + } + + @Override + public void releaseLock(String lockKey) { + RLock lock = redissonClient.getLock(lockKey); + if (lock.isHeldByCurrentThread()) { + log.info("Release lock ...{}", lockKey); + lock.unlock(); + } + } + + @Override + public void releaseMultiLock(List lockKeys) { + lockKeys.forEach(lockKey -> { + RLock lock = redissonClient.getLock(lockKey); + if (lock.isHeldByCurrentThread()) { + log.info("Release lock ...{}", lockKey); + lock.unlock(); + } + }); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationQueryAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationQueryAdapter.java new file mode 100644 index 000000000..9be8cb362 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/adapter/ReservationQueryAdapter.java @@ -0,0 +1,64 @@ +package project.redis.infrastructure.reservation.adapter; + + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; +import project.redis.application.reservation.port.outbound.ReservationQueryPort; +import project.redis.domain.reservation.Reservation; +import project.redis.domain.seat.Seat; +import project.redis.infrastructure.reservation.entity.ReservationJpaEntity; +import project.redis.infrastructure.reservation.entity.ReservationSeatJpaEntity; +import project.redis.infrastructure.reservation.repository.ReservationJpaRepository; +import project.redis.infrastructure.reservation.repository.ReservationSeatJpaRepository; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; +import project.redis.infrastructure.screening.mapper.ScreeningInfraMapper; +import project.redis.infrastructure.seat.mapper.SeatInfraMapper; + +@Component +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReservationQueryAdapter implements ReservationQueryPort { + + private final ReservationJpaRepository reservationJpaRepository; + private final ReservationSeatJpaRepository reservationSeatJpaRepository; + + @Override + public List getReservations(UUID screeningId) { + List reservations = reservationJpaRepository.findAllByScreeningId(screeningId); + + if (CollectionUtils.isEmpty(reservations)) { + return List.of(); + } + + List reservationSeats = reservationSeatJpaRepository.findByScreeningId(screeningId); + + Map> reservationIdToEntityMap = reservationSeats.stream() + .collect(Collectors.groupingBy( + reservationSeatJpaEntity -> reservationSeatJpaEntity.getReservation().getId())); + + return reservations.stream() + .map(reservation -> { + List seats = reservationIdToEntityMap.get(reservation.getId()).stream() + .map(ReservationSeatJpaEntity::getSeat) + .map(SeatInfraMapper::toSeat) + .toList(); + + ScreeningJpaEntity screening = reservation.getScreening(); + + return Reservation.generateReservation( + reservation.getId(), + reservation.getReservationTime(), + reservation.getUsername(), + ScreeningInfraMapper.toScreening(screening), + seats + ); + }) + .toList(); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationJpaEntity.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationJpaEntity.java new file mode 100644 index 000000000..2c4a91fa6 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationJpaEntity.java @@ -0,0 +1,48 @@ +package project.redis.infrastructure.reservation.entity; + + +import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.UuidGenerator; +import project.redis.infrastructure.common.entity.BaseJpaEntity; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; + +@Entity +@Builder +@Table(name = "reservation") +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ReservationJpaEntity extends BaseJpaEntity { + + @Id + @GeneratedValue(generator = "uuid") + @UuidGenerator + @Column(name = "reservation_id", columnDefinition = "BINARY(16)") + private UUID id; + + @Column(nullable = false) + private LocalDateTime reservationTime; + + @Column(nullable = false) + private String username; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "screening_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private ScreeningJpaEntity screening; +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationSeatJpaEntity.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationSeatJpaEntity.java new file mode 100644 index 000000000..a4fdb79c8 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/entity/ReservationSeatJpaEntity.java @@ -0,0 +1,56 @@ +package project.redis.infrastructure.reservation.entity; + + +import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.UuidGenerator; +import project.redis.infrastructure.common.entity.BaseJpaEntity; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; +import project.redis.infrastructure.seat.entity.SeatJpaEntity; + +@Entity +@Builder +@Table(name = "reservation_seat", uniqueConstraints = { + @UniqueConstraint( + name = "UK_screening_seat", + columnNames = {"screening_id", "seat_id"} + ) +}) +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ReservationSeatJpaEntity extends BaseJpaEntity { + + @Id + @GeneratedValue(generator = "uuid") + @UuidGenerator + @Column(name = "reservation_seat_id", columnDefinition = "BINARY(16)") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reservation_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private ReservationJpaEntity reservation; + + @ManyToOne + @JoinColumn(name = "screening_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private ScreeningJpaEntity screening; + + @ManyToOne + @JoinColumn(name = "seat_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) + private SeatJpaEntity seat; +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/mapper/ReservationInfraMapper.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/mapper/ReservationInfraMapper.java new file mode 100644 index 000000000..36106ba1c --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/mapper/ReservationInfraMapper.java @@ -0,0 +1,18 @@ +package project.redis.infrastructure.reservation.mapper; + +import project.redis.domain.reservation.Reservation; +import project.redis.infrastructure.reservation.entity.ReservationJpaEntity; +import project.redis.infrastructure.screening.mapper.ScreeningInfraMapper; + +public class ReservationInfraMapper { + + public static ReservationJpaEntity toEntity(Reservation reservation) { + return ReservationJpaEntity.builder() + .id(reservation.getReservationId()) + .reservationTime(reservation.getReservationTime()) + .username(reservation.getUsername()) + .screening(ScreeningInfraMapper.toEntity(reservation.getScreening())) + .build(); + } + +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationJpaRepository.java new file mode 100644 index 000000000..dfae341b3 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationJpaRepository.java @@ -0,0 +1,17 @@ +package project.redis.infrastructure.reservation.repository; + +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import project.redis.infrastructure.reservation.entity.ReservationJpaEntity; + +public interface ReservationJpaRepository extends JpaRepository { + @EntityGraph(attributePaths = { + "screening", + "screening.movie", + "screening.cinema", + "screening.movie.genre" + }) + List findAllByScreeningId(UUID screeningId); +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationSeatJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationSeatJpaRepository.java new file mode 100644 index 000000000..85c8a1f2e --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/reservation/repository/ReservationSeatJpaRepository.java @@ -0,0 +1,12 @@ +package project.redis.infrastructure.reservation.repository; + +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import project.redis.infrastructure.reservation.entity.ReservationSeatJpaEntity; + +public interface ReservationSeatJpaRepository extends JpaRepository { + @EntityGraph(attributePaths = {"seat", "seat.cinema"}) + List findByScreeningId(UUID screeningId); +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/adapter/ScreeningQueryAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/adapter/ScreeningQueryAdapter.java new file mode 100644 index 000000000..27b75f6e5 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/adapter/ScreeningQueryAdapter.java @@ -0,0 +1,81 @@ +package project.redis.infrastructure.screening.adapter; + +import static project.redis.infrastructure.common.config.RedisConfig.REDIS_SCREENING; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import project.redis.application.screening.port.outbound.ScreeningQueryFilter; +import project.redis.application.screening.port.outbound.ScreeningQueryPort; +import project.redis.domain.screening.Screening; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; +import project.redis.infrastructure.screening.mapper.ScreeningInfraMapper; +import project.redis.infrastructure.screening.repository.ScreeningJpaRepository; + + +@Transactional(readOnly = true) +@Component +@RequiredArgsConstructor +public class ScreeningQueryAdapter implements ScreeningQueryPort { + + private final ScreeningJpaRepository screeningJpaRepository; + + @Override + public List getScreenings(ScreeningQueryFilter filter) { + + LocalDate maxScreeningDate = LocalDate.now().plusDays(filter.getMaxScreeningDay()); + + List screeningsByFilter = screeningJpaRepository.findScreeningsByFilter( + maxScreeningDate, + filter.getGenreName(), + filter.getMovieName() + ); + + return screeningsByFilter.stream() + .map(ScreeningInfraMapper::toScreening) + .toList(); + } + + @Override + @Cacheable(value = REDIS_SCREENING, cacheManager = "redisCacheManager", keyGenerator = "screeningKeyGenerator") + public List getScreeningsRedis(ScreeningQueryFilter filter) { + LocalDate maxScreeningDate = LocalDate.now().plusDays(filter.getMaxScreeningDay()); + + List screeningsByFilter = screeningJpaRepository.findScreeningsByFilter( + maxScreeningDate, + filter.getGenreName(), + filter.getMovieName() + ); + + return screeningsByFilter.stream() + .map(ScreeningInfraMapper::toScreening) + .collect(Collectors.toList()); + } + + @Override + @Cacheable(value = REDIS_SCREENING, cacheManager = "localCacheManager", keyGenerator = "screeningKeyGenerator") + public List getScreeningsLocalCache(ScreeningQueryFilter filter) { + LocalDate maxScreeningDate = LocalDate.now().plusDays(filter.getMaxScreeningDay()); + + List screeningsByFilter = screeningJpaRepository.findScreeningsByFilter( + maxScreeningDate, + filter.getGenreName(), + filter.getMovieName() + ); + + return screeningsByFilter.stream() + .map(ScreeningInfraMapper::toScreening) + .toList(); + } + + @Override + public Screening getScreening(UUID screeningId) { + ScreeningJpaEntity screeningEntity = screeningJpaRepository.findByIdOrThrow(screeningId); + return ScreeningInfraMapper.toScreening(screeningEntity); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/entity/ScreeningJpaEntity.java b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/entity/ScreeningJpaEntity.java new file mode 100644 index 000000000..3003bc50f --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/entity/ScreeningJpaEntity.java @@ -0,0 +1,55 @@ +package project.redis.infrastructure.screening.entity; + + +import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.UuidGenerator; +import project.redis.infrastructure.cinema.entity.CinemaJpaEntity; +import project.redis.infrastructure.common.entity.BaseJpaEntity; +import project.redis.infrastructure.movie.entity.MovieJpaEntity; + +@Entity +@Builder +@Table(name = "screening") +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ScreeningJpaEntity extends BaseJpaEntity { + + @Id + @GeneratedValue(generator = "uuid") + @UuidGenerator + @Column(name = "screening_id", columnDefinition = "BINARY(16)") + private UUID id; + + @Column(nullable = false) + private LocalDateTime screeningStartTime; + + @Column(nullable = false) + private LocalDateTime screeningEndTime; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "movie_id", columnDefinition = "BINARY(16)", + foreignKey = @ForeignKey(value = ConstraintMode.NO_CONSTRAINT)) + private MovieJpaEntity movie; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cinema_id", columnDefinition = "BINARY(16)", + foreignKey = @ForeignKey(value = ConstraintMode.NO_CONSTRAINT)) + private CinemaJpaEntity cinema; +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/mapper/ScreeningInfraMapper.java b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/mapper/ScreeningInfraMapper.java new file mode 100644 index 000000000..2027678df --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/mapper/ScreeningInfraMapper.java @@ -0,0 +1,31 @@ +package project.redis.infrastructure.screening.mapper; + + +import project.redis.domain.screening.Screening; +import project.redis.infrastructure.cinema.mapper.CinemaInfraMapper; +import project.redis.infrastructure.movie.mapper.MovieInfraMapper; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; + +public class ScreeningInfraMapper { + + public static Screening toScreening(ScreeningJpaEntity screening) { + return Screening.generateScreening( + screening.getId(), + screening.getScreeningStartTime(), + screening.getScreeningEndTime(), + MovieInfraMapper.toMovie(screening.getMovie()), + CinemaInfraMapper.toCinema(screening.getCinema()) + ); + } + + public static ScreeningJpaEntity toEntity(Screening screening) { + return ScreeningJpaEntity.builder() + .id(screening.getScreeningId()) + .screeningEndTime(screening.getScreenEndTime()) + .screeningStartTime(screening.getScreenStartTime()) + .movie(MovieInfraMapper.toEntity(screening.getMovie())) + .cinema(CinemaInfraMapper.toEntity(screening.getCinema())) + .build(); + } + +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepository.java new file mode 100644 index 000000000..bffb40f34 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepository.java @@ -0,0 +1,31 @@ +package project.redis.infrastructure.screening.repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import project.redis.common.exception.DataInvalidException; +import project.redis.common.exception.ErrorCode; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; + +public interface ScreeningJpaRepository extends JpaRepository, ScreeningJpaRepositoryCustom { + + @Query("select s from ScreeningJpaEntity s " + + "left join fetch s.movie m " + + "left join fetch s.movie.genre g " + + "left join fetch s.cinema c " + + "where date(s.screeningStartTime) BETWEEN current_date AND :limit " + + "order by s.screeningStartTime asc, m.releaseDate desc") + List findAllOrderByReleaseDescAndScreenStartTimeAsc(@Param("limit") LocalDate limit); + + @EntityGraph(attributePaths = {"movie", "cinema", "movie.genre"}) + Optional findOneById(UUID screeningId); + + default ScreeningJpaEntity findByIdOrThrow(UUID id) { + return findOneById(id).orElseThrow(() -> new DataInvalidException(ErrorCode.NOT_FOUND, id)); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepositoryCustom.java b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepositoryCustom.java new file mode 100644 index 000000000..51880325d --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepositoryCustom.java @@ -0,0 +1,16 @@ +package project.redis.infrastructure.screening.repository; + +import java.time.LocalDate; +import java.util.List; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; + +public interface ScreeningJpaRepositoryCustom { + + List findScreeningsByFilter( + LocalDate maxScreeningDate, + String genreName, + String movieName + ); + +} + diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepositoryCustomImpl.java b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepositoryCustomImpl.java new file mode 100644 index 000000000..72f3a6239 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/screening/repository/ScreeningJpaRepositoryCustomImpl.java @@ -0,0 +1,63 @@ +package project.redis.infrastructure.screening.repository; + +import static org.springframework.util.StringUtils.hasText; +import static project.redis.infrastructure.cinema.entity.QCinemaJpaEntity.cinemaJpaEntity; +import static project.redis.infrastructure.genre.entity.QGenreJpaEntity.genreJpaEntity; +import static project.redis.infrastructure.movie.entity.QMovieJpaEntity.movieJpaEntity; +import static project.redis.infrastructure.screening.entity.QScreeningJpaEntity.screeningJpaEntity; + +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; + +@Component +@RequiredArgsConstructor +public class ScreeningJpaRepositoryCustomImpl implements ScreeningJpaRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List findScreeningsByFilter(LocalDate maxScreeningDate, String genreName, + String movieName) { + + List> orderSpecifiers = new ArrayList<>(); + orderSpecifiers.add(new OrderSpecifier<>(Order.ASC, screeningJpaEntity.screeningStartTime)); + orderSpecifiers.add(new OrderSpecifier<>(Order.DESC, screeningJpaEntity.movie.releaseDate)); + + return queryFactory.selectFrom(screeningJpaEntity) + .leftJoin(screeningJpaEntity.movie, movieJpaEntity).fetchJoin() + .leftJoin(screeningJpaEntity.movie.genre, genreJpaEntity).fetchJoin() + .leftJoin(screeningJpaEntity.cinema, cinemaJpaEntity).fetchJoin() + .where( + genreNameEq(genreName), + movieNameEq(movieName), + withInScreeningDay(maxScreeningDate) + ) + .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) + .fetch(); + } + + private BooleanExpression withInScreeningDay(LocalDate maxScreeningDate) { + LocalDate now = LocalDate.now(); + return screeningJpaEntity.screeningStartTime.between(now.atStartOfDay(), maxScreeningDate.atStartOfDay()); + } + + private BooleanExpression movieNameEq(String movieName) { + return hasText(movieName) + ? movieJpaEntity.title.eq(movieName) + : null; + } + + private BooleanExpression genreNameEq(String genreName) { + return hasText(genreName) + ? genreJpaEntity.genreName.eq(genreName) + : null; + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/seat/adapter/SeatQueryAdapter.java b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/adapter/SeatQueryAdapter.java new file mode 100644 index 000000000..41d6cc1f2 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/adapter/SeatQueryAdapter.java @@ -0,0 +1,27 @@ +package project.redis.infrastructure.seat.adapter; + +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import project.redis.application.seat.port.outbound.SeatQueryPort; +import project.redis.domain.seat.Seat; +import project.redis.infrastructure.seat.mapper.SeatInfraMapper; +import project.redis.infrastructure.seat.respository.SeatJpaRepository; + + +@Transactional(readOnly = true) +@Component +@RequiredArgsConstructor +public class SeatQueryAdapter implements SeatQueryPort { + + private final SeatJpaRepository seatJpaRepository; + + @Override + public List getSeats(List seatIds) { + return seatJpaRepository.findByIdIn(seatIds).stream() + .map(SeatInfraMapper::toSeatOnly) + .toList(); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/seat/entity/SeatJpaEntity.java b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/entity/SeatJpaEntity.java new file mode 100644 index 000000000..9839cc365 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/entity/SeatJpaEntity.java @@ -0,0 +1,44 @@ +package project.redis.infrastructure.seat.entity; + + +import jakarta.persistence.Column; +import jakarta.persistence.ConstraintMode; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.ForeignKey; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.UuidGenerator; +import project.redis.infrastructure.cinema.entity.CinemaJpaEntity; +import project.redis.infrastructure.common.entity.BaseJpaEntity; + +@Entity +@Builder +@Table(name = "seat") +@Getter +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class SeatJpaEntity extends BaseJpaEntity { + @Id + @GeneratedValue(generator = "uuid") + @UuidGenerator + @Column(name = "seat_id", columnDefinition = "BINARY(16)") + private UUID id; + + @Column(nullable = false) + private String seatNumber; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "cinema_id", columnDefinition = "BINARY(16)", + foreignKey = @ForeignKey(value = ConstraintMode.NO_CONSTRAINT)) + private CinemaJpaEntity cinema; +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/seat/mapper/SeatInfraMapper.java b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/mapper/SeatInfraMapper.java new file mode 100644 index 000000000..dd5d5c435 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/mapper/SeatInfraMapper.java @@ -0,0 +1,36 @@ +package project.redis.infrastructure.seat.mapper; + +import project.redis.domain.cinema.Cinema; +import project.redis.domain.seat.Seat; +import project.redis.infrastructure.cinema.mapper.CinemaInfraMapper; +import project.redis.infrastructure.seat.entity.SeatJpaEntity; + +public class SeatInfraMapper { + + public static Seat toSeatOnly(SeatJpaEntity seatJpaEntity) { + return Seat.generateSeat( + seatJpaEntity.getId(), + seatJpaEntity.getSeatNumber(), + Cinema.generateCinema(seatJpaEntity.getCinema().getId(), null) + ); + } + + public static Seat toSeat(SeatJpaEntity seatJpaEntity) { + return Seat.generateSeat( + seatJpaEntity.getId(), + seatJpaEntity.getSeatNumber(), + Cinema.generateCinema( + seatJpaEntity.getCinema().getId(), + seatJpaEntity.getCinema().getCinemaName() + ) + ); + } + + public static SeatJpaEntity toEntity(Seat seat) { + return SeatJpaEntity.builder() + .id(seat.getSeatId()) + .cinema(CinemaInfraMapper.toEntity(seat.getCinema())) + .seatNumber(seat.getSeatNumber()) + .build(); + } +} diff --git a/module-infrastructure/src/main/java/project/redis/infrastructure/seat/respository/SeatJpaRepository.java b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/respository/SeatJpaRepository.java new file mode 100644 index 000000000..6587377a3 --- /dev/null +++ b/module-infrastructure/src/main/java/project/redis/infrastructure/seat/respository/SeatJpaRepository.java @@ -0,0 +1,16 @@ +package project.redis.infrastructure.seat.respository; + +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import project.redis.infrastructure.seat.entity.SeatJpaEntity; + +public interface SeatJpaRepository extends JpaRepository { + + List findByIdIn(Collection ids); + + @EntityGraph(attributePaths = {"cinema"}) + List findByCinemaId(UUID cinemaId); +} diff --git a/module-infrastructure/src/test/java/project/redis/infrastructure/IntegrationTestConfiguration.java b/module-infrastructure/src/test/java/project/redis/infrastructure/IntegrationTestConfiguration.java new file mode 100644 index 000000000..ac50f3074 --- /dev/null +++ b/module-infrastructure/src/test/java/project/redis/infrastructure/IntegrationTestConfiguration.java @@ -0,0 +1,7 @@ +package project.redis.infrastructure; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication(scanBasePackages = {"project.redis.infrastructure", "project.redis.application"}) +public class IntegrationTestConfiguration { +} diff --git a/module-infrastructure/src/test/java/project/redis/infrastructure/TestConfiguration.java b/module-infrastructure/src/test/java/project/redis/infrastructure/TestConfiguration.java new file mode 100644 index 000000000..9b45dae3d --- /dev/null +++ b/module-infrastructure/src/test/java/project/redis/infrastructure/TestConfiguration.java @@ -0,0 +1,7 @@ +package project.redis.infrastructure; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication(scanBasePackages = {"project.redis.infrastructure"}) +public class TestConfiguration { +} diff --git a/module-infrastructure/src/test/java/project/redis/infrastructure/integration/reservation/ReservationCommandServiceIntegrationTest.java b/module-infrastructure/src/test/java/project/redis/infrastructure/integration/reservation/ReservationCommandServiceIntegrationTest.java new file mode 100644 index 000000000..2333ab9a0 --- /dev/null +++ b/module-infrastructure/src/test/java/project/redis/infrastructure/integration/reservation/ReservationCommandServiceIntegrationTest.java @@ -0,0 +1,144 @@ +package project.redis.infrastructure.integration.reservation; + + +import static java.util.concurrent.Executors.newFixedThreadPool; +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestConstructor; +import project.redis.application.reservation.port.inbound.ReserveCommandParam; +import project.redis.application.reservation.service.ReservationCommandService; +import project.redis.domain.screening.Screening; +import project.redis.domain.seat.Seat; +import project.redis.infrastructure.IntegrationTestConfiguration; +import project.redis.infrastructure.reservation.entity.ReservationJpaEntity; +import project.redis.infrastructure.reservation.entity.ReservationSeatJpaEntity; +import project.redis.infrastructure.reservation.repository.ReservationJpaRepository; +import project.redis.infrastructure.reservation.repository.ReservationSeatJpaRepository; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; +import project.redis.infrastructure.screening.mapper.ScreeningInfraMapper; +import project.redis.infrastructure.screening.repository.ScreeningJpaRepository; +import project.redis.infrastructure.seat.mapper.SeatInfraMapper; +import project.redis.infrastructure.seat.respository.SeatJpaRepository; +import project.redis.infrastructure.util.TestContainerSupport; + +@Slf4j +@SpringBootTest +@ContextConfiguration(classes = IntegrationTestConfiguration.class) +@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) +@RequiredArgsConstructor +public class ReservationCommandServiceIntegrationTest extends TestContainerSupport { + + private final ReservationCommandService reservationCommandService; + + private final ReservationJpaRepository reservationJpaRepository; + private final ReservationSeatJpaRepository reservationSeatJpaRepository; + private final ScreeningJpaRepository screeningJpaRepository; + private final SeatJpaRepository seatJpaRepository; + + @AfterEach + void tearDown() { + reservationSeatJpaRepository.deleteAll(); + reservationJpaRepository.deleteAll(); + } + + @DisplayName("상영 예약 동시성 테스트") + @Test + void testReservationConcurrency() throws InterruptedException { + LocalDate limitDay = LocalDate.now().plusDays(2); + List screenings = screeningJpaRepository.findAllOrderByReleaseDescAndScreenStartTimeAsc( + limitDay); + + Screening screening = screenings.stream() + .filter(screeningJpaEntity -> + screeningJpaEntity.getScreeningStartTime().isAfter(LocalDateTime.now()) + ) + .findFirst() + .map(ScreeningInfraMapper::toScreening) + .get(); + + List seatIds = seatJpaRepository.findByCinemaId(screening.getCinema().getCinemaId()).stream() + .filter(seatJpaEntity -> + seatJpaEntity.getSeatNumber().contains("A")) + .map(SeatInfraMapper::toSeat) + .map(Seat::getSeatId) + .collect(Collectors.toList()); + + int threadCount = 10; + ExecutorService executorService = newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + for (int i = 0; i < threadCount; i++) { + int finalI = i; + executorService.execute(() -> { + try { + + ReserveCommandParam param = new ReserveCommandParam(seatIds, screening.getScreeningId(), + "user-" + Thread.currentThread().getId()); + reservationCommandService.reserve(param); + + } catch (Exception e) { + log.info("failed to reserve reservation {}", finalI); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + List reservations = reservationJpaRepository.findAll(); + List reservationSeats = reservationSeatJpaRepository.findAll(); + + assertThat(reservations.size()).isEqualTo(1); + assertThat(reservationSeats.size()).isEqualTo(5); + } + + + @DisplayName("상영 예약 테스트") + @Test + void testReservation() throws InterruptedException { + LocalDate limitDay = LocalDate.now().plusDays(2); + List screenings = screeningJpaRepository.findAllOrderByReleaseDescAndScreenStartTimeAsc( + limitDay); + + Screening screening = screenings.stream() + .filter(screeningJpaEntity -> + screeningJpaEntity.getScreeningStartTime().isAfter(LocalDateTime.now()) + ) + .findFirst() + .map(ScreeningInfraMapper::toScreening) + .get(); + + List seatIds = seatJpaRepository.findByCinemaId(screening.getCinema().getCinemaId()).stream() + .filter(seatJpaEntity -> + seatJpaEntity.getSeatNumber().contains("A")) + .map(SeatInfraMapper::toSeat) + .map(Seat::getSeatId) + .collect(Collectors.toList()); + + ReserveCommandParam param = new ReserveCommandParam(seatIds, screening.getScreeningId(), + "user-" + Thread.currentThread().getId()); + reservationCommandService.reserve(param); + + List reservations = reservationJpaRepository.findAll(); + List reservationSeats = reservationSeatJpaRepository.findAll(); + + assertThat(reservations.size()).isEqualTo(1); + assertThat(reservationSeats.size()).isEqualTo(5); + } +} diff --git a/module-infrastructure/src/test/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapterTest.java b/module-infrastructure/src/test/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapterTest.java new file mode 100644 index 000000000..df3be794d --- /dev/null +++ b/module-infrastructure/src/test/java/project/redis/infrastructure/reservation/adapter/ReservationCommandAdapterTest.java @@ -0,0 +1,143 @@ +package project.redis.infrastructure.reservation.adapter; + +import static java.util.concurrent.Executors.newFixedThreadPool; +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestConstructor; +import project.redis.domain.reservation.Reservation; +import project.redis.domain.screening.Screening; +import project.redis.domain.seat.Seat; +import project.redis.infrastructure.TestConfiguration; +import project.redis.infrastructure.reservation.entity.ReservationJpaEntity; +import project.redis.infrastructure.reservation.entity.ReservationSeatJpaEntity; +import project.redis.infrastructure.reservation.repository.ReservationJpaRepository; +import project.redis.infrastructure.reservation.repository.ReservationSeatJpaRepository; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; +import project.redis.infrastructure.screening.mapper.ScreeningInfraMapper; +import project.redis.infrastructure.screening.repository.ScreeningJpaRepository; +import project.redis.infrastructure.seat.mapper.SeatInfraMapper; +import project.redis.infrastructure.seat.respository.SeatJpaRepository; +import project.redis.infrastructure.util.TestContainerSupport; + + +@Slf4j +@SpringBootTest +@ContextConfiguration(classes = TestConfiguration.class) +@TestConstructor(autowireMode = TestConstructor.AutowireMode.ALL) +@RequiredArgsConstructor +class ReservationCommandAdapterTest extends TestContainerSupport { + + private final ReservationCommandAdapter reservationCommandAdapter; + private final ReservationJpaRepository reservationJpaRepository; + private final ReservationSeatJpaRepository reservationSeatJpaRepository; + private final SeatJpaRepository seatJpaRepository; + private final ScreeningJpaRepository screeningJpaRepository; + + @AfterEach + void tearDown() { + reservationSeatJpaRepository.deleteAll(); + reservationJpaRepository.deleteAll(); + } + + @Test + void testReserve() { + LocalDate limitDay = LocalDate.now().plusDays(2); + List screenings = screeningJpaRepository.findAllOrderByReleaseDescAndScreenStartTimeAsc( + limitDay); + + Screening screening = screenings.stream() + .filter(screeningJpaEntity -> + screeningJpaEntity.getScreeningStartTime().isAfter(LocalDateTime.now()) + ) + .findFirst() + .map(ScreeningInfraMapper::toScreening) + .get(); + + List seats = seatJpaRepository.findByCinemaId(screening.getCinema().getCinemaId()).stream() + .filter(seatJpaEntity -> + seatJpaEntity.getSeatNumber().contains("A")) + .map(SeatInfraMapper::toSeat) + .toList(); + + Reservation reservation = Reservation.generateReservation( + null, + LocalDateTime.now(), + "hongs", + screening, + seats); + + reservationCommandAdapter.reserve(reservation); + + List reservations = reservationJpaRepository.findAll(); + List reservationSeats = reservationSeatJpaRepository.findAll(); + + assertThat(reservations.size()).isEqualTo(1); + assertThat(reservationSeats.size()).isEqualTo(5); + } + + @Test + void testReserveConcurrencyTest() throws InterruptedException { + LocalDate limitDay = LocalDate.now().plusDays(2); + List screenings = screeningJpaRepository.findAllOrderByReleaseDescAndScreenStartTimeAsc( + limitDay); + + Screening screening = screenings.stream() + .filter(screeningJpaEntity -> + screeningJpaEntity.getScreeningStartTime().isAfter(LocalDateTime.now()) + ) + .findFirst() + .map(ScreeningInfraMapper::toScreening) + .get(); + + List seats = seatJpaRepository.findByCinemaId(screening.getCinema().getCinemaId()).stream() + .filter(seatJpaEntity -> + seatJpaEntity.getSeatNumber().contains("A")) + .map(SeatInfraMapper::toSeat) + .toList(); + + int threadCount = 10; + ExecutorService executorService = newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + + for (int i = 0; i < threadCount; i++) { + int finalI = i; + executorService.execute(() -> { + try { + Reservation reservation = Reservation.generateReservation( + null, + LocalDateTime.now(), + "user-" + Thread.currentThread().getId(), + screening, + seats + ); + + reservationCommandAdapter.reserve(reservation); + } catch (Exception e) { + log.info("failed to reserve reservation {}", finalI); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executorService.shutdown(); + + List reservations = reservationJpaRepository.findAll(); + List reservationSeats = reservationSeatJpaRepository.findAll(); + + assertThat(reservations.size()).isEqualTo(1); + assertThat(reservationSeats.size()).isEqualTo(5); + } +} \ No newline at end of file diff --git a/module-infrastructure/src/test/java/project/redis/infrastructure/util/ScreeningDataInit.java b/module-infrastructure/src/test/java/project/redis/infrastructure/util/ScreeningDataInit.java new file mode 100644 index 000000000..7a5b93279 --- /dev/null +++ b/module-infrastructure/src/test/java/project/redis/infrastructure/util/ScreeningDataInit.java @@ -0,0 +1,70 @@ +package project.redis.infrastructure.util; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Stream; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; +import project.redis.infrastructure.cinema.entity.CinemaJpaEntity; +import project.redis.infrastructure.cinema.repository.CinemaJpaRepository; +import project.redis.infrastructure.movie.entity.MovieJpaEntity; +import project.redis.infrastructure.movie.repository.MovieJpaRepository; +import project.redis.infrastructure.screening.entity.ScreeningJpaEntity; +import project.redis.infrastructure.screening.repository.ScreeningJpaRepository; + +@Component +@RequiredArgsConstructor +public class ScreeningDataInit implements CommandLineRunner { + + private final ScreeningJpaRepository screeningJpaRepository; + private final MovieJpaRepository movieJpaRepository; + private final CinemaJpaRepository cinemaJpaRepository; + + private static final Random RANDOM = new Random(); + + @Override + public void run(String... args) throws Exception { + List movies = movieJpaRepository.findAll(); + List cinemas = cinemaJpaRepository.findAll(); + + Stream.iterate(0, i -> i + 1) + .limit(500) + .parallel() + .map(index -> { + + MovieJpaEntity movieJpaEntity = movies.get(RANDOM.nextInt(movies.size())); + CinemaJpaEntity cinemaJpaEntity = cinemas.get(RANDOM.nextInt(cinemas.size())); + + LocalDateTime startTime = generateRandomStartTime(); + LocalDateTime endTime = startTime.plusMinutes(movieJpaEntity.getRunningMinTime()); + + return ScreeningJpaEntity.builder() + .screeningStartTime(startTime) + .screeningEndTime(endTime) + .movie(movieJpaEntity) + .cinema(cinemaJpaEntity) + .build(); + + }) + .forEach(screeningJpaRepository::save); + + } + + public LocalDateTime generateRandomStartTime() { + LocalDate today = LocalDate.now(); + LocalDate startDate = today.plusDays(1); + LocalDate endDate = today.plusDays(20); + + long randomDays = ThreadLocalRandom.current() + .nextLong(ChronoUnit.DAYS.between(startDate, endDate) + 1); + + return startDate + .plusDays(randomDays) + .atTime(new Random().nextInt(18), new Random().nextInt(60)); + } +} diff --git a/module-infrastructure/src/test/java/project/redis/infrastructure/util/TestContainerSupport.java b/module-infrastructure/src/test/java/project/redis/infrastructure/util/TestContainerSupport.java new file mode 100644 index 000000000..e0a7ec1fb --- /dev/null +++ b/module-infrastructure/src/test/java/project/redis/infrastructure/util/TestContainerSupport.java @@ -0,0 +1,44 @@ +package project.redis.infrastructure.util; + +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; + + +public abstract class TestContainerSupport { + + @Container + public static final MySQLContainer mysqlContainer + = new MySQLContainer<>("mysql:9.1.0") + .withDatabaseName("db") + .withUsername("user") + .withPassword("1234") + .withCommand( + "--character-set-server=utf8mb4", + "--collation-server=utf8mb4_unicode_ci" + ); + + @Container + public static final GenericContainer redisContainer + = new GenericContainer<>("redis") + .withExposedPorts(6379); + + static { + mysqlContainer.start(); + redisContainer.start(); + } + + @DynamicPropertySource + static void setDatasourceProperties(DynamicPropertyRegistry registry) { + System.out.println("mysqlContainer = " + mysqlContainer.getJdbcUrl()); + registry.add("spring.datasource.url", ()-> mysqlContainer.getJdbcUrl() + "?useSSL=false&allowPublicKeyRetrieval=true"); + registry.add("spring.datasource.username", mysqlContainer::getUsername); + registry.add("spring.datasource.password", mysqlContainer::getPassword); + registry.add("spring.datasource.driver-class-name", mysqlContainer::getDriverClassName); + + registry.add("redis.host", redisContainer::getHost); + registry.add("redis.port", () -> redisContainer.getMappedPort(6379).toString()); + } +} diff --git a/module-infrastructure/src/test/resources/application.yaml b/module-infrastructure/src/test/resources/application.yaml new file mode 100644 index 000000000..e389ed929 --- /dev/null +++ b/module-infrastructure/src/test/resources/application.yaml @@ -0,0 +1,6 @@ +spring: + flyway: + enabled: true + baseline-on-migrate: true + placeholder-replacement: false + locations: classpath:db/migration diff --git a/module-infrastructure/src/test/resources/db/migration/V1__CreateInitTable.sql b/module-infrastructure/src/test/resources/db/migration/V1__CreateInitTable.sql new file mode 100644 index 000000000..dff8960ba --- /dev/null +++ b/module-infrastructure/src/test/resources/db/migration/V1__CreateInitTable.sql @@ -0,0 +1,75 @@ +-- genre 테이블 생성 +create table genre +( + genre_id binary(16) not null primary key, + genre_name varchar(255) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null +) engine = innodb + charset = utf8mb4; + +-- movie 테이블 생성 +create table movie +( + movie_id binary(16) not null primary key, + title varchar(255) not null, + rating varchar(255) not null, + release_date date not null, + thumbnail_url text, + running_min_time int not null, + genre_id binary(16) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null, + constraint fk_movie_genre foreign key (genre_id) references genre (genre_id) +) engine = innodb + charset = utf8mb4; + + +-- cinema 테이블 생성 +create table cinema +( + cinema_id binary(16) not null primary key, + cinema_name varchar(255) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null +) engine = innodb + charset = utf8mb4; + + +-- screening 테이블 생성 +create table screening +( + screening_id binary(16) not null primary key, + screening_start_time datetime(6) not null, + screening_end_time datetime(6) not null, + movie_id binary(16) not null, + cinema_id binary(16) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null, + constraint fk_screening_movie foreign key (movie_id) references movie (movie_id), + constraint fk_screening_cinema foreign key (cinema_id) references cinema (cinema_id) +) engine = innodb + charset = utf8mb4; + + +-- seat 테이블 생성 +create table seat +( + seat_id binary(16) not null primary key, + seat_number varchar(255) not null, + cinema_id binary(16) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null, + constraint fk_seat_cinema foreign key (cinema_id) references cinema (cinema_id) +) engine = innodb + charset = utf8mb4; \ No newline at end of file diff --git a/module-infrastructure/src/test/resources/db/migration/V2__InitData.sql b/module-infrastructure/src/test/resources/db/migration/V2__InitData.sql new file mode 100644 index 000000000..ae31805dc --- /dev/null +++ b/module-infrastructure/src/test/resources/db/migration/V2__InitData.sql @@ -0,0 +1,180 @@ +insert into genre (genre_id, genre_name) +values (uuid_to_bin(uuid()), '액션'), + (uuid_to_bin(uuid()), '코미디'), + (uuid_to_bin(uuid()), '드라마'), + (uuid_to_bin(uuid()), '판타지'), + (uuid_to_bin(uuid()), '로맨스'); + +insert into cinema (cinema_id, cinema_name) +values (uuid_to_bin(uuid()), '스타라이트 상영관'), + (uuid_to_bin(uuid()), '드림씨어터'), + (uuid_to_bin(uuid()), '선셋 극장'), + (uuid_to_bin(uuid()), '루프탑 상영관'), + (uuid_to_bin(uuid()), '클래식 상영관'); + +insert into seat (seat_id, seat_number, cinema_id) +values + -- 루프탑 상영관 좌석 + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + + -- 클래식 상영관 좌석 + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '선셋 극장')); + + +insert into movie (movie_id, title, rating, release_date, thumbnail_url, running_min_time, genre_id) +values + -- 액션 장르 영화 + (uuid_to_bin(uuid()), '매드 맥스: 분노의 도로', 'NINETEEN', '2015-05-15', 'https://example.com/madmax.jpg', 120, + (select genre_id from genre where genre_name = '액션')), + (uuid_to_bin(uuid()), '다이하드', 'NINETEEN', '1988-07-20', 'https://example.com/diehard.jpg', 131, + (select genre_id from genre where genre_name = '액션')), + + -- 코미디 장르 영화 + (uuid_to_bin(uuid()), '슈퍼배드', 'TWELVE', '2010-07-09', 'https://example.com/despicableme.jpg', 95, + (select genre_id from genre where genre_name = '코미디')), + (uuid_to_bin(uuid()), '트루먼 쇼', 'TWELVE', '1998-06-05', 'https://example.com/trumanshow.jpg', 103, + (select genre_id from genre where genre_name = '코미디')), + + -- 드라마 장르 영화 + (uuid_to_bin(uuid()), '쇼생크 탈출', 'FIFTEEN', '1994-09-23', 'https://example.com/shawshank.jpg', 142, + (select genre_id from genre where genre_name = '드라마')), + (uuid_to_bin(uuid()), '포레스트 검프', 'TWELVE', '1994-07-06', 'https://example.com/forrestgump.jpg', 144, + (select genre_id from genre where genre_name = '드라마')), + + -- 판타지 장르 영화 + (uuid_to_bin(uuid()), '반지의 제왕: 반지 원정대', 'FIFTEEN', '2001-12-19', 'https://example.com/lotr.jpg', 178, + (select genre_id from genre where genre_name = '판타지')), + (uuid_to_bin(uuid()), '해리 포터와 마법사의 돌', 'TWELVE', '2001-11-16', 'https://example.com/harrypotter.jpg', 152, + (select genre_id from genre where genre_name = '판타지')), + + -- 로맨스 장르 영화 + (uuid_to_bin(uuid()), '타이타닉', 'FIFTEEN', '1997-12-19', 'https://example.com/titanic.jpg', 195, + (select genre_id from genre where genre_name = '로맨스')), + (uuid_to_bin(uuid()), '노트북', 'FIFTEEN', '2004-06-25', 'https://example.com/notebook.jpg', 123, + (select genre_id from genre where genre_name = '로맨스')); diff --git a/module-infrastructure/src/test/resources/db/migration/V3__DropForeignKeyAllTables.sql b/module-infrastructure/src/test/resources/db/migration/V3__DropForeignKeyAllTables.sql new file mode 100644 index 000000000..b2e4cd447 --- /dev/null +++ b/module-infrastructure/src/test/resources/db/migration/V3__DropForeignKeyAllTables.sql @@ -0,0 +1,4 @@ +ALTER TABLE movie DROP FOREIGN KEY fk_movie_genre; +ALTER TABLE screening DROP FOREIGN KEY fk_screening_movie; +ALTER TABLE screening DROP FOREIGN KEY fk_screening_cinema; +ALTER TABLE seat DROP FOREIGN KEY fk_seat_cinema; \ No newline at end of file diff --git a/module-infrastructure/src/test/resources/db/migration/V4__CreateReservationRelatedTables.sql b/module-infrastructure/src/test/resources/db/migration/V4__CreateReservationRelatedTables.sql new file mode 100644 index 000000000..7decc80c5 --- /dev/null +++ b/module-infrastructure/src/test/resources/db/migration/V4__CreateReservationRelatedTables.sql @@ -0,0 +1,25 @@ +create table reservation +( + reservation_id binary(16) not null primary key, + username varchar(255) not null, + screening_id binary(16) not null, + reservation_time datetime(6) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null +) engine=innodb charset=utf8mb4; + + +create table reservation_seat +( + reservation_seat_id binary(16) not null primary key, + reservation_id binary(16) not null, + screening_id binary(16) not null, + seat_id binary(16) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null, + constraint UK_screening_seat unique (screening_id, seat_id) +) engine=innodb charset=utf8mb4; \ No newline at end of file diff --git a/module-presentation/README.md b/module-presentation/README.md new file mode 100644 index 000000000..485fa71e7 --- /dev/null +++ b/module-presentation/README.md @@ -0,0 +1,100 @@ +## [Presentation Module] + +### 책임 + +1. **Client의 요청 처리** + - 클라이언트로부터 들어오는 요청을 처리합니다. + - 요청 데이터(`ScreeningsQueryRequest`)를 바탕으로 유효성을 검증하고 필요한 데이터를 추출합니다. + +2. **Application 모듈에 로직 위임** + - 요청 데이터를 기반으로 Application 모듈의 인터페이스(`ScreeningQueryUseCase`)를 호출합니다. + - 비즈니스 로직은 Application 모듈에서 처리하며, Presentation 모듈은 단순히 요청/응답에만 집중합니다. + +3. **적절한 응답 반환** + - Application 모듈에서 반환된 결과를 바탕으로 클라이언트가 원하는 응답 형식으로 변환합니다. + - 예: 영화 데이터를 `GroupedScreeningResponse`로 그룹화하여 반환합니다. + +### 구조 + +#### **Presentation과 Application 모듈의 분리** +- Presentation 모듈은 Application 모듈과 직접적으로 의존하지 않고, **Port-Adapter 패턴**을 통해 통신합니다. + - **Port**: `ScreeningQueryUseCase` 인터페이스로, Application 모듈에서 구현됩니다. + - **Adapter**: Application 모듈의 실제 구현체가 주입됩니다. + +#### **파라미터 설계** +- 클라이언트로부터 받은 요청(`ScreeningsQueryRequest`)은 Presentation 모듈 내에서 처리됩니다. +- Application 모듈과의 통신에는 별도의 파라미터 객체(`ScreeningsQueryParam`)를 사용하여 **Presentation의 변경이 Application에 영향을 미치지 않도록 설계**했습니다. + +### 주요 클래스 + +#### **`ScreeningController`** +- 클라이언트 요청을 처리하고 응답을 반환하는 API 컨트롤러. + +```java +@RestController +@RequestMapping("/api/v1/screenings") +@RequiredArgsConstructor +public class ScreeningController { + + private final ScreeningQueryUseCase screeningQueryUseCase; + + @GetMapping + public ResponseEntity> getScreenings(ScreeningsQueryRequest request) { + + List screenings = screeningQueryUseCase.getScreenings( + ScreeningsQueryParam.builder() + .maxScreeningDay(request.getMaxScreeningDay()) + .build() + ); + + return ResponseEntity.ok(ScreeningAppMapper.toGroupedScreeningResponse(screenings)); + } +} +``` + +#### **`ScreeningsQueryRequest`** +- 클라이언트로부터 전달된 요청 데이터를 캡슐화한 클래스. + +```java +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ScreeningsQueryRequest { + @Builder.Default + private int maxScreeningDay = 2; +} +``` + +#### **`GroupedScreeningResponse`** +- 영화별로 그룹화된 응답 데이터를 제공하는 DTO. + +```java +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class GroupedScreeningResponse { + private String movieId; + private String movieTitle; + private String rating; + private LocalDate releaseDate; + private String thumbnailUrl; + private int runningMinTime; + private String genreId; + private String genreName; + private List screenings; + + @Data + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class ScreeningDetail { + private String screeningId; + private LocalDateTime screeningStartTime; + private LocalDateTime screeningEndTime; + private String cinemaId; + private String cinemaName; + } +} +``` \ No newline at end of file diff --git a/module-presentation/build.gradle b/module-presentation/build.gradle new file mode 100644 index 000000000..33b994db0 --- /dev/null +++ b/module-presentation/build.gradle @@ -0,0 +1,32 @@ +plugins { + id 'org.springframework.boot' version '3.4.1' +} + +jar { + enabled = false +} + +bootJar { + enabled = true +} + +bootRun { + enabled = true +} + + +group = 'project.redis.presentation' +version = '0.0.1-SNAPSHOT' + +dependencies { + implementation project(':module-application') + implementation project(':module-infrastructure') + implementation project(':module-domain') + implementation project(':module-common') + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} diff --git a/module-presentation/src/main/java/project/redis/presentation/TheaterApplication.java b/module-presentation/src/main/java/project/redis/presentation/TheaterApplication.java new file mode 100644 index 000000000..8fafd0fa5 --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/TheaterApplication.java @@ -0,0 +1,39 @@ +package project.redis.presentation; + + +import java.util.Locale; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.MessageSource; +import org.springframework.context.annotation.Bean; +import org.springframework.context.support.ReloadableResourceBundleMessageSource; +import org.springframework.web.servlet.LocaleResolver; +import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver; + + +@SpringBootApplication(scanBasePackages = { + "project.redis.application", + "project.redis.presentation", + "project.redis.domain", + "project.redis.infrastructure" +}) +public class TheaterApplication { + public static void main(String[] args) { + SpringApplication.run(TheaterApplication.class, args); + } + + @Bean + public MessageSource messageSource() { + ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource(); + messageSource.setBasename("classpath:i18n/messages"); + messageSource.setDefaultEncoding("UTF-8"); + return messageSource; + } + + @Bean + public LocaleResolver localeResolver() { + AcceptHeaderLocaleResolver localeResolver = new AcceptHeaderLocaleResolver(); + localeResolver.setDefaultLocale(Locale.KOREA); + return localeResolver; + } +} diff --git a/module-presentation/src/main/java/project/redis/presentation/cinema/controller/CinemaController.java b/module-presentation/src/main/java/project/redis/presentation/cinema/controller/CinemaController.java new file mode 100644 index 000000000..36798ce72 --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/cinema/controller/CinemaController.java @@ -0,0 +1,49 @@ +package project.redis.presentation.cinema.controller; + + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import project.redis.application.cinema.port.inbound.CinemaCommandUseCase; +import project.redis.application.cinema.port.inbound.CinemaCreateCommandParam; +import project.redis.application.cinema.port.inbound.CinemaQueryUseCase; +import project.redis.domain.cinema.Cinema; +import project.redis.presentation.cinema.dto.request.CinemaCreateRequest; +import project.redis.presentation.cinema.dto.response.CinemaResponse; +import project.redis.presentation.cinema.mapper.CinemaApiMapper; + +@RestController +@RequestMapping("/api/v1/cinemas") +@RequiredArgsConstructor +public class CinemaController { + + private final CinemaQueryUseCase cinemaQueryUseCase; + private final CinemaCommandUseCase cinemaCommandUseCase; + + @GetMapping + public ResponseEntity> getCinemas() { + List cinemas = cinemaQueryUseCase.getCinemas(); + + List result = cinemas.stream() + .map(CinemaApiMapper::toCinemaResponse) + .toList(); + + return ResponseEntity.ok(result); + } + + @PostMapping + public ResponseEntity createCinema(@RequestBody CinemaCreateRequest request) { + + CinemaCreateCommandParam command = new CinemaCreateCommandParam(request.getCinemaName()); + + cinemaCommandUseCase.createCinema(command); + + return ResponseEntity.status(HttpStatus.CREATED).build(); + } +} diff --git a/module-presentation/src/main/java/project/redis/presentation/cinema/dto/request/CinemaCreateRequest.java b/module-presentation/src/main/java/project/redis/presentation/cinema/dto/request/CinemaCreateRequest.java new file mode 100644 index 000000000..db7ad33cd --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/cinema/dto/request/CinemaCreateRequest.java @@ -0,0 +1,20 @@ +package project.redis.presentation.cinema.dto.request; + + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CinemaCreateRequest { + + @NotNull + @NotBlank + private String cinemaName; +} diff --git a/module-presentation/src/main/java/project/redis/presentation/cinema/dto/response/CinemaResponse.java b/module-presentation/src/main/java/project/redis/presentation/cinema/dto/response/CinemaResponse.java new file mode 100644 index 000000000..d7de6b4fb --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/cinema/dto/response/CinemaResponse.java @@ -0,0 +1,17 @@ +package project.redis.presentation.cinema.dto.response; + + +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class CinemaResponse { + private UUID cinemaId; + private String cinemaName; +} diff --git a/module-presentation/src/main/java/project/redis/presentation/cinema/exception/ErrorResponse.java b/module-presentation/src/main/java/project/redis/presentation/cinema/exception/ErrorResponse.java new file mode 100644 index 000000000..4cb762746 --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/cinema/exception/ErrorResponse.java @@ -0,0 +1,12 @@ +package project.redis.presentation.cinema.exception; + + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ErrorResponse { + private String errorCode; + private String errorMessage; +} diff --git a/module-presentation/src/main/java/project/redis/presentation/cinema/exception/GlobalExceptionHandler.java b/module-presentation/src/main/java/project/redis/presentation/cinema/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..6619cd62b --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/cinema/exception/GlobalExceptionHandler.java @@ -0,0 +1,54 @@ +package project.redis.presentation.cinema.exception; + + +import jakarta.validation.ConstraintViolationException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.context.MessageSource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import project.redis.common.exception.DataInvalidException; + +@RestControllerAdvice +@RequiredArgsConstructor +public class GlobalExceptionHandler { + + private final MessageSource messageSource; + + @ExceptionHandler({ConstraintViolationException.class}) + public ResponseEntity>> handleConstraintViolationException( + ConstraintViolationException e, Locale locale) { + Map> errors = new HashMap<>(); + e.getConstraintViolations() + .forEach(constraintViolation -> { + String errorCode = constraintViolation.getMessageTemplate(); + String message = messageSource.getMessage(errorCode, null, locale); + + errors.computeIfAbsent( + constraintViolation.getPropertyPath().toString(), + key -> new ArrayList<>() + ).add(new ErrorResponse(errorCode, message)); + + }); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST.value()).body(errors); + } + + @ExceptionHandler(DataInvalidException.class) + public ResponseEntity handleDataInvalidException( + DataInvalidException e, Locale locale) { + String errorCode = e.getErrorCode().getMessageId(); + Object[] args = e.getArgs(); + String message = messageSource.getMessage(errorCode, args, locale); + + ErrorResponse errorResponse = new ErrorResponse(errorCode, message); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } +} diff --git a/module-presentation/src/main/java/project/redis/presentation/cinema/mapper/CinemaApiMapper.java b/module-presentation/src/main/java/project/redis/presentation/cinema/mapper/CinemaApiMapper.java new file mode 100644 index 000000000..0a673cf8f --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/cinema/mapper/CinemaApiMapper.java @@ -0,0 +1,15 @@ +package project.redis.presentation.cinema.mapper; + +import project.redis.domain.cinema.Cinema; +import project.redis.presentation.cinema.dto.response.CinemaResponse; + +public class CinemaApiMapper { + + public static CinemaResponse toCinemaResponse(Cinema cinema) { + return CinemaResponse.builder() + .cinemaId(cinema.getCinemaId()) + .cinemaName(cinema.getCinemaName()) + .build(); + } + +} diff --git a/module-presentation/src/main/java/project/redis/presentation/movie/controller/MovieController.java b/module-presentation/src/main/java/project/redis/presentation/movie/controller/MovieController.java new file mode 100644 index 000000000..605175ed9 --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/movie/controller/MovieController.java @@ -0,0 +1,33 @@ +package project.redis.presentation.movie.controller; + + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import project.redis.presentation.movie.dto.response.MovieResponse; +import project.redis.application.movie.port.inbound.MovieQueryUseCase; +import project.redis.domain.movie.Movie; +import project.redis.presentation.movie.mapper.MovieApiMapper; + + +@RestController +@RequestMapping("/api/v1/movies") +@RequiredArgsConstructor +public class MovieController { + + public final MovieQueryUseCase movieQueryUseCase; + + @GetMapping + public ResponseEntity> getMovie() { + List movies = movieQueryUseCase.getMovies(); + + List result = movies.stream() + .map(MovieApiMapper::toMovieResponse) + .toList(); + + return ResponseEntity.ok(result); + } +} diff --git a/module-presentation/src/main/java/project/redis/presentation/movie/dto/response/MovieResponse.java b/module-presentation/src/main/java/project/redis/presentation/movie/dto/response/MovieResponse.java new file mode 100644 index 000000000..9710be6ec --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/movie/dto/response/MovieResponse.java @@ -0,0 +1,24 @@ +package project.redis.presentation.movie.dto.response; + + +import java.time.LocalDate; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class MovieResponse { + + private UUID movieId; + private String title; + private String rating; + private LocalDate releaseDate; + private String thumbnailUrl; + private int runningTimeMin; + private String genreName; +} diff --git a/module-presentation/src/main/java/project/redis/presentation/movie/mapper/MovieApiMapper.java b/module-presentation/src/main/java/project/redis/presentation/movie/mapper/MovieApiMapper.java new file mode 100644 index 000000000..0b0d719f6 --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/movie/mapper/MovieApiMapper.java @@ -0,0 +1,18 @@ +package project.redis.presentation.movie.mapper; + +import project.redis.domain.movie.Movie; +import project.redis.presentation.movie.dto.response.MovieResponse; + +public class MovieApiMapper { + + public static MovieResponse toMovieResponse(Movie movie) { + return MovieResponse.builder() + .movieId(movie.getMovieId()) + .title(movie.getTitle()) + .rating(movie.getRating().toString()) + .releaseDate(movie.getReleaseDate()) + .runningTimeMin(movie.getRunningMinTime()) + .genreName(movie.getGenre().getGenreName()) + .build(); + } +} diff --git a/module-presentation/src/main/java/project/redis/presentation/reservation/controller/ReservationController.java b/module-presentation/src/main/java/project/redis/presentation/reservation/controller/ReservationController.java new file mode 100644 index 000000000..16e9957a9 --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/reservation/controller/ReservationController.java @@ -0,0 +1,32 @@ +package project.redis.presentation.reservation.controller; + + +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import project.redis.application.reservation.port.inbound.ReservationCommandUseCase; +import project.redis.application.reservation.port.inbound.ReserveCommandParam; +import project.redis.presentation.reservation.dto.request.ReservationCommandRequest; + +@RestController +@RequestMapping("/api/v1/reservations") +@RequiredArgsConstructor +public class ReservationController { + + private final ReservationCommandUseCase reservationCommandUseCase; + + @PostMapping + public ResponseEntity createReservation(@RequestBody ReservationCommandRequest request) { + + ReserveCommandParam param = new ReserveCommandParam(request.getSeatIds(), + request.getScreeningId(), request.getUsername()); + + reservationCommandUseCase.reserve(param); + + return ResponseEntity.status(HttpStatus.CREATED).build(); + } +} diff --git a/module-presentation/src/main/java/project/redis/presentation/reservation/dto/request/ReservationCommandRequest.java b/module-presentation/src/main/java/project/redis/presentation/reservation/dto/request/ReservationCommandRequest.java new file mode 100644 index 000000000..20bc30f4b --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/reservation/dto/request/ReservationCommandRequest.java @@ -0,0 +1,16 @@ +package project.redis.presentation.reservation.dto.request; + +import java.util.List; +import java.util.UUID; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ReservationCommandRequest { + private String username; + private UUID screeningId; + private List seatIds; +} diff --git a/module-presentation/src/main/java/project/redis/presentation/screening/controller/ScreeningController.java b/module-presentation/src/main/java/project/redis/presentation/screening/controller/ScreeningController.java new file mode 100644 index 000000000..6f7b4309d --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/screening/controller/ScreeningController.java @@ -0,0 +1,63 @@ +package project.redis.presentation.screening.controller; + + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import project.redis.application.screening.port.inbound.ScreeningQueryUseCase; +import project.redis.application.screening.port.inbound.ScreeningsQueryParam; +import project.redis.domain.screening.Screening; +import project.redis.presentation.screening.dto.request.ScreeningsQueryRequest; +import project.redis.presentation.screening.dto.response.GroupedScreeningResponse; +import project.redis.presentation.screening.mapper.ScreeningAppMapper; + +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class ScreeningController { + + private final ScreeningQueryUseCase screeningQueryUseCase; + + @GetMapping("/v1/screenings") + public ResponseEntity> getScreenings(ScreeningsQueryRequest request) { + + List screenings = screeningQueryUseCase.getScreenings( + ScreeningsQueryParam.builder() + .maxScreeningDay(request.getMaxScreeningDay()) + .genreName(request.getGenreName()) + .movieName(request.getMovieName()) + .build() + ); + + return ResponseEntity.ok(ScreeningAppMapper.toGroupedScreeningResponse(screenings)); + } + + @GetMapping("/v2/screenings/local-caching") + public ResponseEntity> getScreeningsLocalCaching(ScreeningsQueryRequest request) { + List screenings = screeningQueryUseCase.getScreenings( + ScreeningsQueryParam.builder() + .maxScreeningDay(request.getMaxScreeningDay()) + .genreName(request.getGenreName()) + .movieName(request.getMovieName()) + .build() + ); + + return ResponseEntity.ok(ScreeningAppMapper.toGroupedScreeningResponse(screenings)); + } + + @GetMapping("/v3/screenings/redis") + public ResponseEntity> getScreeningsRedis(ScreeningsQueryRequest request) { + List screenings = screeningQueryUseCase.getScreeningsRedis( + ScreeningsQueryParam.builder() + .maxScreeningDay(request.getMaxScreeningDay()) + .genreName(request.getGenreName()) + .movieName(request.getMovieName()) + .build() + ); + + return ResponseEntity.ok(ScreeningAppMapper.toGroupedScreeningResponse(screenings)); + } +} diff --git a/module-presentation/src/main/java/project/redis/presentation/screening/dto/request/ScreeningsQueryRequest.java b/module-presentation/src/main/java/project/redis/presentation/screening/dto/request/ScreeningsQueryRequest.java new file mode 100644 index 000000000..2d523905b --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/screening/dto/request/ScreeningsQueryRequest.java @@ -0,0 +1,19 @@ +package project.redis.presentation.screening.dto.request; + +import java.util.ArrayList; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class ScreeningsQueryRequest { + @Builder.Default + private int maxScreeningDay = 2; + private String movieName; + private String genreName; +} diff --git a/module-presentation/src/main/java/project/redis/presentation/screening/dto/response/GroupedScreeningResponse.java b/module-presentation/src/main/java/project/redis/presentation/screening/dto/response/GroupedScreeningResponse.java new file mode 100644 index 000000000..5f4910af2 --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/screening/dto/response/GroupedScreeningResponse.java @@ -0,0 +1,39 @@ +package project.redis.presentation.screening.dto.response; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class GroupedScreeningResponse { + + private String movieId; + private String movieTitle; + private String rating; + private LocalDate releaseDate; + private String thumbnailUrl; + private int runningMinTime; + private String genreId; + private String genreName; + private List screenings; + + + @Data + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class ScreeningDetail { + private String screeningId; + private LocalDateTime screeningStartTime; + private LocalDateTime screeningEndTime; + private String cinemaId; + private String cinemaName; + } +} diff --git a/module-presentation/src/main/java/project/redis/presentation/screening/mapper/ScreeningAppMapper.java b/module-presentation/src/main/java/project/redis/presentation/screening/mapper/ScreeningAppMapper.java new file mode 100644 index 000000000..959b236d1 --- /dev/null +++ b/module-presentation/src/main/java/project/redis/presentation/screening/mapper/ScreeningAppMapper.java @@ -0,0 +1,52 @@ +package project.redis.presentation.screening.mapper; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; +import project.redis.domain.movie.Movie; +import project.redis.domain.screening.Screening; +import project.redis.presentation.screening.dto.response.GroupedScreeningResponse; +import project.redis.presentation.screening.dto.response.GroupedScreeningResponse.ScreeningDetail; + +public class ScreeningAppMapper { + + public static List toGroupedScreeningResponse(List screenings) { + Map> groupedByMovie = screenings.stream() + .collect(Collectors.groupingBy(screening -> screening.getMovie().getMovieId())); + + return groupedByMovie.entrySet().stream() + .sorted(Comparator.comparing(entry -> + entry.getValue().get(0).getMovie().getReleaseDate(), + Comparator.reverseOrder()) + ) + .map(entry -> { + Movie movie = entry.getValue().get(0).getMovie(); + List screeningDetails = entry.getValue().stream() + .map(screening -> + ScreeningDetail.builder() + .screeningId(screening.getScreeningId().toString()) + .screeningStartTime(screening.getScreenStartTime()) + .screeningEndTime(screening.getScreenEndTime()) + .cinemaId(screening.getCinema().getCinemaId().toString()) + .cinemaName(screening.getCinema().getCinemaName()) + .build() + ) + .toList(); + + return GroupedScreeningResponse.builder() + .movieId(movie.getMovieId().toString()) + .movieTitle(movie.getTitle()) + .rating(movie.getRating().toString()) + .releaseDate(movie.getReleaseDate()) + .thumbnailUrl(movie.getThumbnailUrl()) + .runningMinTime(movie.getRunningMinTime()) + .genreId(movie.getGenre().getGenreId().toString()) + .genreName(movie.getGenre().getGenreName()) + .screenings(screeningDetails) + .build(); + }) + .toList(); + } +} diff --git a/module-presentation/src/main/resources/application.yaml b/module-presentation/src/main/resources/application.yaml new file mode 100644 index 000000000..f80206d62 --- /dev/null +++ b/module-presentation/src/main/resources/application.yaml @@ -0,0 +1,49 @@ +spring: + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://localhost:3309/redis-movie?useSSL=false&allowPublicKeyRetrieval=true + username: hongs + password: local1234 + + jpa: + hibernate: + ddl-auto: validate + open-in-view: false + show-sql: true + properties: + hibernate: + format_sql: true + jdbc: + batch_size: 100 + + + flyway: + enabled: true + baseline-on-migrate: true + placeholder-replacement: false + locations: classpath:db/migration + + messages: + basename: i18n/messages + + +logging: + level: + org: + hibernate: + SQL: info + type: + descriptor: + sql: + info + +redis: + host: localhost + port: 6380 + + +management: + endpoints: + web: + exposure: + include: "*" \ No newline at end of file diff --git a/module-presentation/src/main/resources/db/migration/V1__CreateInitTable.sql b/module-presentation/src/main/resources/db/migration/V1__CreateInitTable.sql new file mode 100644 index 000000000..dff8960ba --- /dev/null +++ b/module-presentation/src/main/resources/db/migration/V1__CreateInitTable.sql @@ -0,0 +1,75 @@ +-- genre 테이블 생성 +create table genre +( + genre_id binary(16) not null primary key, + genre_name varchar(255) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null +) engine = innodb + charset = utf8mb4; + +-- movie 테이블 생성 +create table movie +( + movie_id binary(16) not null primary key, + title varchar(255) not null, + rating varchar(255) not null, + release_date date not null, + thumbnail_url text, + running_min_time int not null, + genre_id binary(16) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null, + constraint fk_movie_genre foreign key (genre_id) references genre (genre_id) +) engine = innodb + charset = utf8mb4; + + +-- cinema 테이블 생성 +create table cinema +( + cinema_id binary(16) not null primary key, + cinema_name varchar(255) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null +) engine = innodb + charset = utf8mb4; + + +-- screening 테이블 생성 +create table screening +( + screening_id binary(16) not null primary key, + screening_start_time datetime(6) not null, + screening_end_time datetime(6) not null, + movie_id binary(16) not null, + cinema_id binary(16) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null, + constraint fk_screening_movie foreign key (movie_id) references movie (movie_id), + constraint fk_screening_cinema foreign key (cinema_id) references cinema (cinema_id) +) engine = innodb + charset = utf8mb4; + + +-- seat 테이블 생성 +create table seat +( + seat_id binary(16) not null primary key, + seat_number varchar(255) not null, + cinema_id binary(16) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null, + constraint fk_seat_cinema foreign key (cinema_id) references cinema (cinema_id) +) engine = innodb + charset = utf8mb4; \ No newline at end of file diff --git a/module-presentation/src/main/resources/db/migration/V2__InitData.sql b/module-presentation/src/main/resources/db/migration/V2__InitData.sql new file mode 100644 index 000000000..ae31805dc --- /dev/null +++ b/module-presentation/src/main/resources/db/migration/V2__InitData.sql @@ -0,0 +1,180 @@ +insert into genre (genre_id, genre_name) +values (uuid_to_bin(uuid()), '액션'), + (uuid_to_bin(uuid()), '코미디'), + (uuid_to_bin(uuid()), '드라마'), + (uuid_to_bin(uuid()), '판타지'), + (uuid_to_bin(uuid()), '로맨스'); + +insert into cinema (cinema_id, cinema_name) +values (uuid_to_bin(uuid()), '스타라이트 상영관'), + (uuid_to_bin(uuid()), '드림씨어터'), + (uuid_to_bin(uuid()), '선셋 극장'), + (uuid_to_bin(uuid()), '루프탑 상영관'), + (uuid_to_bin(uuid()), '클래식 상영관'); + +insert into seat (seat_id, seat_number, cinema_id) +values + -- 루프탑 상영관 좌석 + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '루프탑 상영관')), + + -- 클래식 상영관 좌석 + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '클래식 상영관')), + + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '스타라이트 상영관')), + + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '드림씨어터')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '드림씨어터')), + + (uuid_to_bin(uuid()), 'A1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'A2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'A3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'A4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'A5', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'B5', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'C5', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'D5', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E1', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E2', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E3', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E4', (select cinema_id from cinema where cinema_name = '선셋 극장')), + (uuid_to_bin(uuid()), 'E5', (select cinema_id from cinema where cinema_name = '선셋 극장')); + + +insert into movie (movie_id, title, rating, release_date, thumbnail_url, running_min_time, genre_id) +values + -- 액션 장르 영화 + (uuid_to_bin(uuid()), '매드 맥스: 분노의 도로', 'NINETEEN', '2015-05-15', 'https://example.com/madmax.jpg', 120, + (select genre_id from genre where genre_name = '액션')), + (uuid_to_bin(uuid()), '다이하드', 'NINETEEN', '1988-07-20', 'https://example.com/diehard.jpg', 131, + (select genre_id from genre where genre_name = '액션')), + + -- 코미디 장르 영화 + (uuid_to_bin(uuid()), '슈퍼배드', 'TWELVE', '2010-07-09', 'https://example.com/despicableme.jpg', 95, + (select genre_id from genre where genre_name = '코미디')), + (uuid_to_bin(uuid()), '트루먼 쇼', 'TWELVE', '1998-06-05', 'https://example.com/trumanshow.jpg', 103, + (select genre_id from genre where genre_name = '코미디')), + + -- 드라마 장르 영화 + (uuid_to_bin(uuid()), '쇼생크 탈출', 'FIFTEEN', '1994-09-23', 'https://example.com/shawshank.jpg', 142, + (select genre_id from genre where genre_name = '드라마')), + (uuid_to_bin(uuid()), '포레스트 검프', 'TWELVE', '1994-07-06', 'https://example.com/forrestgump.jpg', 144, + (select genre_id from genre where genre_name = '드라마')), + + -- 판타지 장르 영화 + (uuid_to_bin(uuid()), '반지의 제왕: 반지 원정대', 'FIFTEEN', '2001-12-19', 'https://example.com/lotr.jpg', 178, + (select genre_id from genre where genre_name = '판타지')), + (uuid_to_bin(uuid()), '해리 포터와 마법사의 돌', 'TWELVE', '2001-11-16', 'https://example.com/harrypotter.jpg', 152, + (select genre_id from genre where genre_name = '판타지')), + + -- 로맨스 장르 영화 + (uuid_to_bin(uuid()), '타이타닉', 'FIFTEEN', '1997-12-19', 'https://example.com/titanic.jpg', 195, + (select genre_id from genre where genre_name = '로맨스')), + (uuid_to_bin(uuid()), '노트북', 'FIFTEEN', '2004-06-25', 'https://example.com/notebook.jpg', 123, + (select genre_id from genre where genre_name = '로맨스')); diff --git a/module-presentation/src/main/resources/db/migration/V3__DropForeignKeyAllTables.sql b/module-presentation/src/main/resources/db/migration/V3__DropForeignKeyAllTables.sql new file mode 100644 index 000000000..b2e4cd447 --- /dev/null +++ b/module-presentation/src/main/resources/db/migration/V3__DropForeignKeyAllTables.sql @@ -0,0 +1,4 @@ +ALTER TABLE movie DROP FOREIGN KEY fk_movie_genre; +ALTER TABLE screening DROP FOREIGN KEY fk_screening_movie; +ALTER TABLE screening DROP FOREIGN KEY fk_screening_cinema; +ALTER TABLE seat DROP FOREIGN KEY fk_seat_cinema; \ No newline at end of file diff --git a/module-presentation/src/main/resources/db/migration/V4__CreateReservationRelatedTables.sql b/module-presentation/src/main/resources/db/migration/V4__CreateReservationRelatedTables.sql new file mode 100644 index 000000000..7decc80c5 --- /dev/null +++ b/module-presentation/src/main/resources/db/migration/V4__CreateReservationRelatedTables.sql @@ -0,0 +1,25 @@ +create table reservation +( + reservation_id binary(16) not null primary key, + username varchar(255) not null, + screening_id binary(16) not null, + reservation_time datetime(6) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null +) engine=innodb charset=utf8mb4; + + +create table reservation_seat +( + reservation_seat_id binary(16) not null primary key, + reservation_id binary(16) not null, + screening_id binary(16) not null, + seat_id binary(16) not null, + created_at datetime(6) null, + updated_at datetime(6) null, + created_by binary(16) null, + updated_by binary(16) null, + constraint UK_screening_seat unique (screening_id, seat_id) +) engine=innodb charset=utf8mb4; \ No newline at end of file diff --git a/module-presentation/src/main/resources/i18n/messages_en.properties b/module-presentation/src/main/resources/i18n/messages_en.properties new file mode 100644 index 000000000..0bd50c5ef --- /dev/null +++ b/module-presentation/src/main/resources/i18n/messages_en.properties @@ -0,0 +1,14 @@ +COMMON.ERROR.NOT_FOUND=Not found resource. +COMMON.ERROR.NOT_NULL=Not null this property. +COMMON.ERROR.NOT_BLANK=Not blank this property. +# +# SEAT +# +SEAT.ERROR.REQUIRED_SERIES=Reservations can only be made for consecutive seats +SEAT.ERROR.DUPLICATED=Some of the seats have already been reserved. +SEAT.ERROR.EXCEED_COUNT=Seats can not be reserved over {0} count. +SEAT.ERROR.ALREADY_RESERVED=The seats already reserved exist. {0} +# +# SCREENING +# +SCREENING.ERROR.REQUIRED_LATER_NOW=This screening schedule has already passed. {0} \ No newline at end of file diff --git a/module-presentation/src/main/resources/i18n/messages_ko.properties b/module-presentation/src/main/resources/i18n/messages_ko.properties new file mode 100644 index 000000000..949f73083 --- /dev/null +++ b/module-presentation/src/main/resources/i18n/messages_ko.properties @@ -0,0 +1,14 @@ +COMMON.ERROR.NOT_FOUND=\uB9AC\uC18C\uC2A4\uB97C \uCC3E\uC744 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. +COMMON.ERROR.NOT_NULL=\uB110 \uC774\uC5B4\uC11C\uB294 \uC548\uB429\uB2C8\uB2E4. +COMMON.ERROR.NOT_BLANK=\uBE48 \uAC12\uC774\uC5B4\uC11C\uB294 \uC548\uB429\uB2C8\uB2E4. +# +# SEAT +# +SEAT.ERROR.REQUIRED_SERIES=\uC5F0\uC18D\uB41C \uC88C\uC11D\uC73C\uB85C\uB9CC \uC608\uC57D\uC774 \uAC00\uB2A5\uD569\uB2C8\uB2E4. +SEAT.ERROR.DUPLICATED=\uC77C\uBD80 \uC88C\uC11D\uC774 \uC774\uBBF8 \uC608\uC57D \uB418\uC5C8\uC2B5\uB2C8\uB2E4. +SEAT.ERROR.EXCEED_COUNT=\uC88C\uC11D\uC740 {0} \uAC1C \uC774\uC0C1 \uC608\uC57D\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. +SEAT.ERROR.ALREADY_RESERVED=\uC774\uBBF8 \uC608\uC57D\uB41C \uC88C\uC11D\uC774 \uC874\uC7AC\uD569\uB2C8\uB2E4. {0} +# +# SCREENING +# +SCREENING.ERROR.REQUIRED_LATER_NOW=\uC774\uBBF8 \uC9C0\uB09C \uC0C1\uC601\uC2DC\uAC04\uD45C \uC785\uB2C8\uB2E4. {0} \ No newline at end of file diff --git a/module-presentation/src/test/java/project/redis/presentation/screening/controller/ScreeningControllerTest.java b/module-presentation/src/test/java/project/redis/presentation/screening/controller/ScreeningControllerTest.java new file mode 100644 index 000000000..5aee0a95f --- /dev/null +++ b/module-presentation/src/test/java/project/redis/presentation/screening/controller/ScreeningControllerTest.java @@ -0,0 +1,115 @@ +package project.redis.presentation.screening.controller; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import project.redis.application.screening.port.inbound.ScreeningQueryUseCase; +import project.redis.application.screening.port.inbound.ScreeningsQueryParam; +import project.redis.domain.cinema.Cinema; +import project.redis.domain.genre.Genre; +import project.redis.domain.movie.Movie; +import project.redis.domain.movie.RatingClassification; +import project.redis.domain.screening.Screening; + +@ExtendWith(MockitoExtension.class) +class ScreeningControllerTest { + + private MockMvc mockMvc; + + @Mock + private ScreeningQueryUseCase screeningQueryUseCase; + + @InjectMocks + private ScreeningController screeningController; + + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(screeningController) + .build(); + } + + + @DisplayName("상영 시간표 가져오기 - 성공") + @Test + void testGetScreenings() throws Exception { + Cinema cinema = Cinema.generateCinema(UUID.randomUUID(), "cinema"); + + UUID genreId = UUID.randomUUID(); + Genre genre = Genre.generateGenre(genreId, "액션"); + + LocalDateTime now = LocalDateTime.now(); + LocalDateTime end = now.plusMinutes(120); + UUID movieIdA = UUID.randomUUID(); + UUID movieIdB = UUID.randomUUID(); + LocalDate releaseDateNow = LocalDate.now(); + + Movie movieA = Movie.generateMovie( + movieIdA, "movieA", RatingClassification.ALL, + releaseDateNow, "thum", 120, genre); + + Movie movieB = Movie.generateMovie( + movieIdB, "movieB", RatingClassification.ALL, + releaseDateNow.plusDays(1), "thum", 120, genre); + + + Screening screening1 = Screening.generateScreening( + UUID.randomUUID(), now, now.plusMinutes(120), movieA, cinema); + + Screening screening2 = Screening.generateScreening( + UUID.randomUUID(), now.plusMinutes(120), now.plusMinutes(240), movieA, cinema); + + Screening screening3 = Screening.generateScreening( + UUID.randomUUID(), now.plusMinutes(240), now.plusMinutes(360), movieB, cinema); + + Screening screening4 = Screening.generateScreening( + UUID.randomUUID(), now.plusMinutes(360), now.plusMinutes(480), movieB, cinema); + + ArrayList screenings = new ArrayList<>(); + screenings.add(screening1); + screenings.add(screening2); + screenings.add(screening3); + screenings.add(screening4); + + when(screeningQueryUseCase.getScreenings(ScreeningsQueryParam.builder() + .maxScreeningDay(2) + .build())).thenReturn(screenings); + + ResultActions resultActions = mockMvc.perform( + get("/api/v1/screenings") + .contentType(MediaType.APPLICATION_JSON_VALUE) + ); + + resultActions + .andDo(print()) + .andExpect(status().isOk()) + .andExpect( + jsonPath("$[0].movieTitle") + .value(movieB.getTitle()) + ) + .andExpect( + jsonPath("$[0].screenings[0].screeningId") + .value(screening3.getScreeningId().toString()) + ); + } + + +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000..17ec3ef6e --- /dev/null +++ b/settings.gradle @@ -0,0 +1,7 @@ +rootProject.name = 'redis_1st' +include 'module-application' +include 'module-presentation' +include 'module-infrastructure' +include 'module-domain' +include 'module-common' +