generated from pricelees/issue-pr-template
load origin project files
This commit is contained in:
parent
6c7f7dd97a
commit
04bf97518e
77
.gitignore
vendored
77
.gitignore
vendored
@ -1,50 +1,37 @@
|
|||||||
# ---> Java
|
HELP.md
|
||||||
# Compiled class file
|
|
||||||
*.class
|
|
||||||
|
|
||||||
# Log file
|
|
||||||
*.log
|
|
||||||
|
|
||||||
# BlueJ files
|
|
||||||
*.ctxt
|
|
||||||
|
|
||||||
# Mobile Tools for Java (J2ME)
|
|
||||||
.mtj.tmp/
|
|
||||||
|
|
||||||
# Package Files #
|
|
||||||
*.jar
|
|
||||||
*.war
|
|
||||||
*.nar
|
|
||||||
*.ear
|
|
||||||
*.zip
|
|
||||||
*.tar.gz
|
|
||||||
*.rar
|
|
||||||
|
|
||||||
# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
|
|
||||||
hs_err_pid*
|
|
||||||
replay_pid*
|
|
||||||
|
|
||||||
# ---> Gradle
|
|
||||||
.gradle
|
.gradle
|
||||||
**/build/
|
build/
|
||||||
!src/**/build/
|
!gradle/wrapper/gradle-wrapper.jar
|
||||||
|
!**/src/main/**/build/
|
||||||
|
!**/src/test/**/build/
|
||||||
|
|
||||||
# Ignore Gradle GUI config
|
### STS ###
|
||||||
gradle-app.setting
|
.apt_generated
|
||||||
|
|
||||||
# Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored)
|
|
||||||
!gradle-wrapper.jar
|
|
||||||
|
|
||||||
# Avoid ignore Gradle wrappper properties
|
|
||||||
!gradle-wrapper.properties
|
|
||||||
|
|
||||||
# Cache of project
|
|
||||||
.gradletasknamecache
|
|
||||||
|
|
||||||
# Eclipse Gradle plugin generated files
|
|
||||||
# Eclipse Core
|
|
||||||
.project
|
|
||||||
# JDT-specific (Eclipse Java Development Tools)
|
|
||||||
.classpath
|
.classpath
|
||||||
|
.factorypath
|
||||||
|
.project
|
||||||
|
.settings
|
||||||
|
.springBeans
|
||||||
|
.sts4-cache
|
||||||
|
bin/
|
||||||
|
!**/src/main/**/bin/
|
||||||
|
!**/src/test/**/bin/
|
||||||
|
|
||||||
|
### IntelliJ IDEA ###
|
||||||
.idea
|
.idea
|
||||||
|
*.iws
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
out/
|
||||||
|
!**/src/main/**/out/
|
||||||
|
!**/src/test/**/out/
|
||||||
|
|
||||||
|
### NetBeans ###
|
||||||
|
/nbproject/private/
|
||||||
|
/nbbuild/
|
||||||
|
/dist/
|
||||||
|
/nbdist/
|
||||||
|
/.nb-gradle/
|
||||||
|
|
||||||
|
### VS Code ###
|
||||||
|
.vscode/
|
||||||
|
|||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 next-step
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
34
build.gradle
Normal file
34
build.gradle
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
plugins {
|
||||||
|
id 'org.springframework.boot' version '3.2.4'
|
||||||
|
id 'io.spring.dependency-management' version '1.1.4'
|
||||||
|
id 'java'
|
||||||
|
}
|
||||||
|
|
||||||
|
group = 'nextstep'
|
||||||
|
version = '0.0.1-SNAPSHOT'
|
||||||
|
sourceCompatibility = '17'
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||||
|
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||||
|
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.5.0'
|
||||||
|
|
||||||
|
implementation 'io.jsonwebtoken:jjwt:0.9.1'
|
||||||
|
implementation 'javax.xml.bind:jaxb-api:2.3.1'
|
||||||
|
|
||||||
|
runtimeOnly 'com.h2database:h2'
|
||||||
|
|
||||||
|
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||||
|
testImplementation 'io.rest-assured:rest-assured:5.3.1'
|
||||||
|
}
|
||||||
|
|
||||||
|
test {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
234
gradlew
vendored
Executable file
234
gradlew
vendored
Executable file
@ -0,0 +1,234 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015-2021 the original authors.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||||
|
|
||||||
|
APP_NAME="Gradle"
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
|
||||||
|
# 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"'
|
||||||
|
|
||||||
|
# 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
|
||||||
|
which java >/dev/null 2>&1 || 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
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
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
|
||||||
|
|
||||||
|
# Collect all arguments for the java command;
|
||||||
|
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||||
|
# shell script including quotes and variable substitutions, so put them in
|
||||||
|
# double quotes to make sure that they get re-expanded; and
|
||||||
|
# * put everything else in single quotes, so that it's not re-expanded.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# 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" "$@"
|
||||||
89
gradlew.bat
vendored
Normal file
89
gradlew.bat
vendored
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
@rem
|
||||||
|
@rem Copyright 2015 the original author or authors.
|
||||||
|
@rem
|
||||||
|
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
@rem you may not use this file except in compliance with the License.
|
||||||
|
@rem You may obtain a copy of the License at
|
||||||
|
@rem
|
||||||
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
@rem
|
||||||
|
@rem Unless required by applicable law or agreed to in writing, software
|
||||||
|
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
@rem See the License for the specific language governing permissions and
|
||||||
|
@rem limitations under the License.
|
||||||
|
@rem
|
||||||
|
|
||||||
|
@if "%DEBUG%" == "" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%" == "" set DIRNAME=.
|
||||||
|
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%" == "0" goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if "%ERRORLEVEL%"=="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!
|
||||||
|
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
15
src/main/java/roomescape/RoomescapeApplication.java
Normal file
15
src/main/java/roomescape/RoomescapeApplication.java
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
package roomescape;
|
||||||
|
|
||||||
|
import org.springframework.boot.Banner.Mode;
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
@SpringBootApplication
|
||||||
|
public class RoomescapeApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication springApplication = new SpringApplication(RoomescapeApplication.class);
|
||||||
|
springApplication.setBannerMode(Mode.OFF);
|
||||||
|
springApplication.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
package roomescape.member.controller;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import roomescape.member.dto.MembersResponse;
|
||||||
|
import roomescape.member.service.MemberService;
|
||||||
|
import roomescape.system.auth.annotation.Admin;
|
||||||
|
import roomescape.system.dto.response.RoomEscapeApiResponse;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@Tag(name = "2. 회원 API", description = "회원 정보를 관리할 때 사용합니다.")
|
||||||
|
public class MemberController {
|
||||||
|
|
||||||
|
private final MemberService memberService;
|
||||||
|
|
||||||
|
public MemberController(MemberService memberService) {
|
||||||
|
this.memberService = memberService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Admin
|
||||||
|
@GetMapping("/members")
|
||||||
|
@ResponseStatus(HttpStatus.OK)
|
||||||
|
@Operation(summary = "모든 회원 조회", tags = "관리자 로그인이 필요한 API")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)
|
||||||
|
})
|
||||||
|
public RoomEscapeApiResponse<MembersResponse> getAllMembers() {
|
||||||
|
return RoomEscapeApiResponse.success(memberService.findAllMembers());
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/main/java/roomescape/member/domain/Member.java
Normal file
98
src/main/java/roomescape/member/domain/Member.java
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
package roomescape.member.domain;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import roomescape.system.exception.ErrorType;
|
||||||
|
import roomescape.system.exception.RoomEscapeException;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
public class Member {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@Enumerated(value = EnumType.STRING)
|
||||||
|
private Role role;
|
||||||
|
|
||||||
|
protected Member() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Member(
|
||||||
|
String name,
|
||||||
|
String email,
|
||||||
|
String password,
|
||||||
|
Role role
|
||||||
|
) {
|
||||||
|
this(null, name, email, password, role);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Member(
|
||||||
|
Long id,
|
||||||
|
String name,
|
||||||
|
String email,
|
||||||
|
String password,
|
||||||
|
Role role
|
||||||
|
) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
this.email = email;
|
||||||
|
this.password = password;
|
||||||
|
this.role = role;
|
||||||
|
|
||||||
|
validateRole();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateRole() {
|
||||||
|
if (role == null) {
|
||||||
|
throw new RoomEscapeException(ErrorType.REQUEST_DATA_BLANK, String.format("[values: %s]", this),
|
||||||
|
HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAdmin() {
|
||||||
|
return this.role == Role.ADMIN;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEmail() {
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPassword() {
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Role getRole() {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "Member{" +
|
||||||
|
"id=" + id +
|
||||||
|
", name=" + name +
|
||||||
|
", email=" + email +
|
||||||
|
", password=" + password +
|
||||||
|
", role=" + role +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/main/java/roomescape/member/domain/Role.java
Normal file
6
src/main/java/roomescape/member/domain/Role.java
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
package roomescape.member.domain;
|
||||||
|
|
||||||
|
public enum Role {
|
||||||
|
MEMBER,
|
||||||
|
ADMIN
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package roomescape.member.domain.repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import roomescape.member.domain.Member;
|
||||||
|
|
||||||
|
public interface MemberRepository extends JpaRepository<Member, Long> {
|
||||||
|
|
||||||
|
Optional<Member> findByEmailAndPassword(String email, String password);
|
||||||
|
}
|
||||||
14
src/main/java/roomescape/member/dto/MemberResponse.java
Normal file
14
src/main/java/roomescape/member/dto/MemberResponse.java
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
package roomescape.member.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import roomescape.member.domain.Member;
|
||||||
|
|
||||||
|
@Schema(name = "회원 조회 응답", description = "회원 정보 조회 응답시 사용됩니다.")
|
||||||
|
public record MemberResponse(
|
||||||
|
@Schema(description = "회원 번호. 회원을 식별할 때 사용합니다.") Long id,
|
||||||
|
@Schema(description = "회원의 이름") String name
|
||||||
|
) {
|
||||||
|
public static MemberResponse fromEntity(Member member) {
|
||||||
|
return new MemberResponse(member.getId(), member.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/main/java/roomescape/member/dto/MembersResponse.java
Normal file
11
src/main/java/roomescape/member/dto/MembersResponse.java
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package roomescape.member.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
@Schema(name = "회원 목록 조회 응답", description = "모든 회원의 정보 조회 응답시 사용됩니다.")
|
||||||
|
public record MembersResponse(
|
||||||
|
@Schema(description = "모든 회원의 ID 및 이름") List<MemberResponse> members
|
||||||
|
) {
|
||||||
|
}
|
||||||
47
src/main/java/roomescape/member/service/MemberService.java
Normal file
47
src/main/java/roomescape/member/service/MemberService.java
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
package roomescape.member.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import roomescape.member.domain.Member;
|
||||||
|
import roomescape.member.domain.repository.MemberRepository;
|
||||||
|
import roomescape.member.dto.MemberResponse;
|
||||||
|
import roomescape.member.dto.MembersResponse;
|
||||||
|
import roomescape.system.exception.ErrorType;
|
||||||
|
import roomescape.system.exception.RoomEscapeException;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class MemberService {
|
||||||
|
|
||||||
|
private final MemberRepository memberRepository;
|
||||||
|
|
||||||
|
public MemberService(MemberRepository memberRepository) {
|
||||||
|
this.memberRepository = memberRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public MembersResponse findAllMembers() {
|
||||||
|
List<MemberResponse> response = memberRepository.findAll().stream()
|
||||||
|
.map(MemberResponse::fromEntity)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return new MembersResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Member findMemberById(Long memberId) {
|
||||||
|
return memberRepository.findById(memberId)
|
||||||
|
.orElseThrow(() -> new RoomEscapeException(ErrorType.MEMBER_NOT_FOUND,
|
||||||
|
String.format("[memberId: %d]", memberId), HttpStatus.BAD_REQUEST));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Member findMemberByEmailAndPassword(String email, String password) {
|
||||||
|
return memberRepository.findByEmailAndPassword(email, password)
|
||||||
|
.orElseThrow(() -> new RoomEscapeException(ErrorType.MEMBER_NOT_FOUND,
|
||||||
|
String.format("[email: %s, password: %s]", email, password), HttpStatus.BAD_REQUEST));
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/main/java/roomescape/payment/PaymentConfig.java
Normal file
38
src/main/java/roomescape/payment/PaymentConfig.java
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
package roomescape.payment;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Base64;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.boot.web.client.ClientHttpRequestFactories;
|
||||||
|
import org.springframework.boot.web.client.ClientHttpRequestFactorySettings;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.client.ClientHttpRequestFactory;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
import roomescape.payment.client.PaymentProperties;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableConfigurationProperties(PaymentProperties.class)
|
||||||
|
public class PaymentConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RestClient.Builder restClientBuilder(PaymentProperties paymentProperties) {
|
||||||
|
ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS
|
||||||
|
.withReadTimeout(Duration.ofSeconds(paymentProperties.getReadTimeout()))
|
||||||
|
.withConnectTimeout(Duration.ofSeconds(paymentProperties.getConnectTimeout()));
|
||||||
|
ClientHttpRequestFactory requestFactory = ClientHttpRequestFactories.get(settings);
|
||||||
|
|
||||||
|
return RestClient.builder().baseUrl("https://api.tosspayments.com")
|
||||||
|
.defaultHeader("Authorization", getAuthorizations(paymentProperties.getConfirmSecretKey()))
|
||||||
|
.requestFactory(requestFactory);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getAuthorizations(String secretKey) {
|
||||||
|
Base64.Encoder encoder = Base64.getEncoder();
|
||||||
|
byte[] encodedBytes = encoder.encode((secretKey + ":").getBytes(StandardCharsets.UTF_8));
|
||||||
|
return "Basic " + new String(encodedBytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
package roomescape.payment.client;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
@ConfigurationProperties(prefix = "payment")
|
||||||
|
public class PaymentProperties {
|
||||||
|
|
||||||
|
private final String confirmSecretKey;
|
||||||
|
private final int readTimeout;
|
||||||
|
private final int connectTimeout;
|
||||||
|
|
||||||
|
public PaymentProperties(String confirmSecretKey, int readTimeout, int connectTimeout) {
|
||||||
|
this.confirmSecretKey = confirmSecretKey;
|
||||||
|
this.readTimeout = readTimeout;
|
||||||
|
this.connectTimeout = connectTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getConfirmSecretKey() {
|
||||||
|
return confirmSecretKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getReadTimeout() {
|
||||||
|
return readTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getConnectTimeout() {
|
||||||
|
return connectTimeout;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,98 @@
|
|||||||
|
package roomescape.payment.client;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatusCode;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.client.ClientHttpResponse;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
|
import roomescape.payment.dto.request.PaymentCancelRequest;
|
||||||
|
import roomescape.payment.dto.request.PaymentRequest;
|
||||||
|
import roomescape.payment.dto.response.PaymentCancelResponse;
|
||||||
|
import roomescape.payment.dto.response.PaymentResponse;
|
||||||
|
import roomescape.payment.dto.response.TossPaymentErrorResponse;
|
||||||
|
import roomescape.system.exception.ErrorType;
|
||||||
|
import roomescape.system.exception.RoomEscapeException;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class TossPaymentClient {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(TossPaymentClient.class);
|
||||||
|
|
||||||
|
private final RestClient restClient;
|
||||||
|
|
||||||
|
public TossPaymentClient(RestClient.Builder restClientBuilder) {
|
||||||
|
this.restClient = restClientBuilder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public PaymentResponse confirmPayment(PaymentRequest paymentRequest) {
|
||||||
|
logPaymentInfo(paymentRequest);
|
||||||
|
return restClient.post()
|
||||||
|
.uri("/v1/payments/confirm")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(paymentRequest)
|
||||||
|
.retrieve()
|
||||||
|
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
|
||||||
|
(req, res) -> handlePaymentError(res))
|
||||||
|
.body(PaymentResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PaymentCancelResponse cancelPayment(PaymentCancelRequest cancelRequest) {
|
||||||
|
logPaymentCancelInfo(cancelRequest);
|
||||||
|
Map<String, String> param = Map.of("cancelReason", cancelRequest.cancelReason());
|
||||||
|
|
||||||
|
return restClient.post()
|
||||||
|
.uri("/v1/payments/{paymentKey}/cancel", cancelRequest.paymentKey())
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.body(param)
|
||||||
|
.retrieve()
|
||||||
|
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
|
||||||
|
(req, res) -> handlePaymentError(res))
|
||||||
|
.body(PaymentCancelResponse.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void logPaymentInfo(PaymentRequest paymentRequest) {
|
||||||
|
log.info("결제 승인 요청: paymentKey={}, orderId={}, amount={}, paymentType={}",
|
||||||
|
paymentRequest.paymentKey(), paymentRequest.orderId(), paymentRequest.amount(),
|
||||||
|
paymentRequest.paymentType());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void logPaymentCancelInfo(PaymentCancelRequest cancelRequest) {
|
||||||
|
log.info("결제 취소 요청: paymentKey={}, amount={}, cancelReason={}",
|
||||||
|
cancelRequest.paymentKey(), cancelRequest.amount(), cancelRequest.cancelReason());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handlePaymentError(ClientHttpResponse res)
|
||||||
|
throws IOException {
|
||||||
|
HttpStatusCode statusCode = res.getStatusCode();
|
||||||
|
ErrorType errorType = getErrorTypeByStatusCode(statusCode);
|
||||||
|
TossPaymentErrorResponse errorResponse = getErrorResponse(res);
|
||||||
|
|
||||||
|
throw new RoomEscapeException(errorType,
|
||||||
|
String.format("[ErrorCode = %s, ErrorMessage = %s]", errorResponse.code(), errorResponse.message()),
|
||||||
|
statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TossPaymentErrorResponse getErrorResponse(ClientHttpResponse res) throws IOException {
|
||||||
|
InputStream body = res.getBody();
|
||||||
|
ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
TossPaymentErrorResponse errorResponse = objectMapper.readValue(body, TossPaymentErrorResponse.class);
|
||||||
|
body.close();
|
||||||
|
return errorResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ErrorType getErrorTypeByStatusCode(HttpStatusCode statusCode) {
|
||||||
|
if (statusCode.is4xxClientError()) {
|
||||||
|
return ErrorType.PAYMENT_ERROR;
|
||||||
|
}
|
||||||
|
return ErrorType.PAYMENT_SERVER_ERROR;
|
||||||
|
}
|
||||||
|
}
|
||||||
67
src/main/java/roomescape/payment/domain/CanceledPayment.java
Normal file
67
src/main/java/roomescape/payment/domain/CanceledPayment.java
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
package roomescape.payment.domain;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import roomescape.system.exception.ErrorType;
|
||||||
|
import roomescape.system.exception.RoomEscapeException;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
public class CanceledPayment {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private String paymentKey;
|
||||||
|
private String cancelReason;
|
||||||
|
private Long cancelAmount;
|
||||||
|
private OffsetDateTime approvedAt;
|
||||||
|
private OffsetDateTime canceledAt;
|
||||||
|
|
||||||
|
protected CanceledPayment() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public CanceledPayment(String paymentKey, String cancelReason, Long cancelAmount, OffsetDateTime approvedAt,
|
||||||
|
OffsetDateTime canceledAt) {
|
||||||
|
validateDate(approvedAt, canceledAt);
|
||||||
|
this.paymentKey = paymentKey;
|
||||||
|
this.cancelReason = cancelReason;
|
||||||
|
this.cancelAmount = cancelAmount;
|
||||||
|
this.approvedAt = approvedAt;
|
||||||
|
this.canceledAt = canceledAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateDate(OffsetDateTime approvedAt, OffsetDateTime canceledAt) {
|
||||||
|
if (canceledAt.isBefore(approvedAt)) {
|
||||||
|
throw new RoomEscapeException(ErrorType.CANCELED_BEFORE_PAYMENT,
|
||||||
|
String.format("[approvedAt: %s, canceledAt: %s]", approvedAt, canceledAt),
|
||||||
|
HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCancelReason() {
|
||||||
|
return cancelReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getCancelAmount() {
|
||||||
|
return cancelAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getApprovedAt() {
|
||||||
|
return approvedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getCanceledAt() {
|
||||||
|
return canceledAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCanceledAt(OffsetDateTime canceledAt) {
|
||||||
|
this.canceledAt = canceledAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/main/java/roomescape/payment/domain/Payment.java
Normal file
108
src/main/java/roomescape/payment/domain/Payment.java
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
package roomescape.payment.domain;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.OneToOne;
|
||||||
|
import roomescape.reservation.domain.Reservation;
|
||||||
|
import roomescape.system.exception.ErrorType;
|
||||||
|
import roomescape.system.exception.RoomEscapeException;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
public class Payment {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String orderId;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String paymentKey;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Long totalAmount;
|
||||||
|
|
||||||
|
@OneToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "reservation_id", nullable = false)
|
||||||
|
private Reservation reservation;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private OffsetDateTime approvedAt;
|
||||||
|
|
||||||
|
protected Payment() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Payment(String orderId, String paymentKey, Long totalAmount, Reservation reservation,
|
||||||
|
OffsetDateTime approvedAt) {
|
||||||
|
validate(orderId, paymentKey, totalAmount, reservation, approvedAt);
|
||||||
|
this.orderId = orderId;
|
||||||
|
this.paymentKey = paymentKey;
|
||||||
|
this.totalAmount = totalAmount;
|
||||||
|
this.reservation = reservation;
|
||||||
|
this.approvedAt = approvedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validate(String orderId, String paymentKey, Long totalAmount, Reservation reservation,
|
||||||
|
OffsetDateTime approvedAt) {
|
||||||
|
validateIsNullOrBlank(orderId, "orderId");
|
||||||
|
validateIsNullOrBlank(paymentKey, "paymentKey");
|
||||||
|
validateIsInvalidAmount(totalAmount);
|
||||||
|
validateIsNull(reservation, "reservation");
|
||||||
|
validateIsNull(approvedAt, "approvedAt");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateIsNullOrBlank(String input, String fieldName) {
|
||||||
|
if (input == null || input.isBlank()) {
|
||||||
|
throw new RoomEscapeException(ErrorType.REQUEST_DATA_BLANK, String.format("[value : %s]", fieldName),
|
||||||
|
HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateIsInvalidAmount(Long totalAmount) {
|
||||||
|
if (totalAmount == null || totalAmount < 0) {
|
||||||
|
throw new RoomEscapeException(ErrorType.INVALID_REQUEST_DATA,
|
||||||
|
String.format("[totalAmount : %d]", totalAmount), HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> void validateIsNull(T value, String fieldName) {
|
||||||
|
if (value == null) {
|
||||||
|
throw new RoomEscapeException(ErrorType.REQUEST_DATA_BLANK, String.format("[value : %s]", fieldName),
|
||||||
|
HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getOrderId() {
|
||||||
|
return orderId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPaymentKey() {
|
||||||
|
return paymentKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getTotalAmount() {
|
||||||
|
return totalAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Reservation getReservation() {
|
||||||
|
return reservation;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getApprovedAt() {
|
||||||
|
return approvedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,12 @@
|
|||||||
|
package roomescape.payment.domain.repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import roomescape.payment.domain.CanceledPayment;
|
||||||
|
|
||||||
|
public interface CanceledPaymentRepository extends JpaRepository<CanceledPayment, Long> {
|
||||||
|
|
||||||
|
Optional<CanceledPayment> findByPaymentKey(String paymentKey);
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package roomescape.payment.domain.repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import roomescape.payment.domain.Payment;
|
||||||
|
|
||||||
|
public interface PaymentRepository extends JpaRepository<Payment, Long> {
|
||||||
|
|
||||||
|
Optional<Payment> findByReservationId(Long reservationId);
|
||||||
|
|
||||||
|
Optional<Payment> findByPaymentKey(String paymentKey);
|
||||||
|
}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
package roomescape.payment.dto.request;
|
||||||
|
|
||||||
|
public record PaymentCancelRequest(String paymentKey, Long amount, String cancelReason) {
|
||||||
|
}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
package roomescape.payment.dto.request;
|
||||||
|
|
||||||
|
public record PaymentRequest(String paymentKey, String orderId, Long amount, String paymentType) {
|
||||||
|
}
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
package roomescape.payment.dto.response;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||||
|
|
||||||
|
@JsonDeserialize(using = PaymentCancelResponseDeserializer.class)
|
||||||
|
public record PaymentCancelResponse(
|
||||||
|
String cancelStatus,
|
||||||
|
String cancelReason,
|
||||||
|
Long cancelAmount,
|
||||||
|
OffsetDateTime canceledAt
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package roomescape.payment.dto.response;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.JsonParser;
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationContext;
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
|
||||||
|
|
||||||
|
public class PaymentCancelResponseDeserializer extends StdDeserializer<PaymentCancelResponse> {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern(
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ssXXX");
|
||||||
|
|
||||||
|
public PaymentCancelResponseDeserializer() {
|
||||||
|
this(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PaymentCancelResponseDeserializer(Class<?> vc) {
|
||||||
|
super(vc);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PaymentCancelResponse deserialize(JsonParser jsonParser, DeserializationContext deserializationContext)
|
||||||
|
throws IOException {
|
||||||
|
JsonNode cancels = (JsonNode)jsonParser.getCodec().readTree(jsonParser).get("cancels").get(0);
|
||||||
|
return new PaymentCancelResponse(
|
||||||
|
cancels.get("cancelStatus").asText(),
|
||||||
|
cancels.get("cancelReason").asText(),
|
||||||
|
cancels.get("cancelAmount").asLong(),
|
||||||
|
OffsetDateTime.parse(cancels.get("canceledAt").asText())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package roomescape.payment.dto.response;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
public record PaymentResponse(
|
||||||
|
String paymentKey,
|
||||||
|
String orderId,
|
||||||
|
OffsetDateTime approvedAt,
|
||||||
|
Long totalAmount
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
package roomescape.payment.dto.response;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
import roomescape.payment.domain.Payment;
|
||||||
|
import roomescape.reservation.dto.response.ReservationResponse;
|
||||||
|
|
||||||
|
public record ReservationPaymentResponse(Long id, String orderId, String paymentKey, Long totalAmount,
|
||||||
|
ReservationResponse reservation, OffsetDateTime approvedAt) {
|
||||||
|
|
||||||
|
public static ReservationPaymentResponse from(Payment saved) {
|
||||||
|
return new ReservationPaymentResponse(saved.getId(), saved.getOrderId(), saved.getPaymentKey(),
|
||||||
|
saved.getTotalAmount(), ReservationResponse.from(saved.getReservation()), saved.getApprovedAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
package roomescape.payment.dto.response;
|
||||||
|
|
||||||
|
public record TossPaymentErrorResponse(String code, String message) {
|
||||||
|
}
|
||||||
82
src/main/java/roomescape/payment/service/PaymentService.java
Normal file
82
src/main/java/roomescape/payment/service/PaymentService.java
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
package roomescape.payment.service;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import roomescape.payment.domain.CanceledPayment;
|
||||||
|
import roomescape.payment.domain.Payment;
|
||||||
|
import roomescape.payment.domain.repository.CanceledPaymentRepository;
|
||||||
|
import roomescape.payment.domain.repository.PaymentRepository;
|
||||||
|
import roomescape.payment.dto.request.PaymentCancelRequest;
|
||||||
|
import roomescape.payment.dto.response.PaymentCancelResponse;
|
||||||
|
import roomescape.payment.dto.response.PaymentResponse;
|
||||||
|
import roomescape.payment.dto.response.ReservationPaymentResponse;
|
||||||
|
import roomescape.reservation.domain.Reservation;
|
||||||
|
import roomescape.system.exception.ErrorType;
|
||||||
|
import roomescape.system.exception.RoomEscapeException;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
public class PaymentService {
|
||||||
|
|
||||||
|
private final PaymentRepository paymentRepository;
|
||||||
|
private final CanceledPaymentRepository canceledPaymentRepository;
|
||||||
|
|
||||||
|
public PaymentService(PaymentRepository paymentRepository, CanceledPaymentRepository canceledPaymentRepository) {
|
||||||
|
this.paymentRepository = paymentRepository;
|
||||||
|
this.canceledPaymentRepository = canceledPaymentRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReservationPaymentResponse savePayment(PaymentResponse paymentResponse, Reservation reservation) {
|
||||||
|
Payment payment = new Payment(paymentResponse.orderId(), paymentResponse.paymentKey(),
|
||||||
|
paymentResponse.totalAmount(), reservation, paymentResponse.approvedAt());
|
||||||
|
Payment saved = paymentRepository.save(payment);
|
||||||
|
return ReservationPaymentResponse.from(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Optional<Payment> findPaymentByReservationId(Long reservationId) {
|
||||||
|
return paymentRepository.findByReservationId(reservationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void saveCanceledPayment(PaymentCancelResponse cancelInfo, OffsetDateTime approvedAt, String paymentKey) {
|
||||||
|
canceledPaymentRepository.save(new CanceledPayment(
|
||||||
|
paymentKey, cancelInfo.cancelReason(), cancelInfo.cancelAmount(), approvedAt, cancelInfo.canceledAt()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public PaymentCancelRequest cancelPaymentByAdmin(Long reservationId) {
|
||||||
|
String paymentKey = findPaymentByReservationId(reservationId)
|
||||||
|
.orElseThrow(() -> new RoomEscapeException(ErrorType.PAYMENT_NOT_POUND,
|
||||||
|
String.format("[reservationId: %d]", reservationId), HttpStatus.NOT_FOUND))
|
||||||
|
.getPaymentKey();
|
||||||
|
// 취소 시간은 현재 시간으로 일단 생성한 뒤, 결제 취소 완료 후 해당 시간으로 변경합니다.
|
||||||
|
CanceledPayment canceled = cancelPayment(paymentKey, "고객 요청", OffsetDateTime.now());
|
||||||
|
|
||||||
|
return new PaymentCancelRequest(paymentKey, canceled.getCancelAmount(), canceled.getCancelReason());
|
||||||
|
}
|
||||||
|
|
||||||
|
private CanceledPayment cancelPayment(String paymentKey, String cancelReason, OffsetDateTime canceledAt) {
|
||||||
|
Payment payment = paymentRepository.findByPaymentKey(paymentKey)
|
||||||
|
.orElseThrow(() -> throwPaymentNotFoundByPaymentKey(paymentKey));
|
||||||
|
paymentRepository.delete(payment);
|
||||||
|
|
||||||
|
return canceledPaymentRepository.save(new CanceledPayment(paymentKey, cancelReason, payment.getTotalAmount(),
|
||||||
|
payment.getApprovedAt(), canceledAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateCanceledTime(String paymentKey, OffsetDateTime canceledAt) {
|
||||||
|
CanceledPayment canceledPayment = canceledPaymentRepository.findByPaymentKey(paymentKey)
|
||||||
|
.orElseThrow(() -> throwPaymentNotFoundByPaymentKey(paymentKey));
|
||||||
|
canceledPayment.setCanceledAt(canceledAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RoomEscapeException throwPaymentNotFoundByPaymentKey(String paymentKey) {
|
||||||
|
return new RoomEscapeException(
|
||||||
|
ErrorType.PAYMENT_NOT_POUND, String.format("[paymentKey: %s]", paymentKey),
|
||||||
|
HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,273 @@
|
|||||||
|
package roomescape.reservation.controller;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.headers.Header;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import roomescape.payment.client.TossPaymentClient;
|
||||||
|
import roomescape.payment.dto.request.PaymentCancelRequest;
|
||||||
|
import roomescape.payment.dto.request.PaymentRequest;
|
||||||
|
import roomescape.payment.dto.response.PaymentCancelResponse;
|
||||||
|
import roomescape.payment.dto.response.PaymentResponse;
|
||||||
|
import roomescape.reservation.dto.request.AdminReservationRequest;
|
||||||
|
import roomescape.reservation.dto.request.ReservationRequest;
|
||||||
|
import roomescape.reservation.dto.request.WaitingRequest;
|
||||||
|
import roomescape.reservation.dto.response.MyReservationsResponse;
|
||||||
|
import roomescape.reservation.dto.response.ReservationResponse;
|
||||||
|
import roomescape.reservation.dto.response.ReservationsResponse;
|
||||||
|
import roomescape.reservation.service.ReservationService;
|
||||||
|
import roomescape.reservation.service.ReservationWithPaymentService;
|
||||||
|
import roomescape.system.auth.annotation.Admin;
|
||||||
|
import roomescape.system.auth.annotation.LoginRequired;
|
||||||
|
import roomescape.system.auth.annotation.MemberId;
|
||||||
|
import roomescape.system.dto.response.ErrorResponse;
|
||||||
|
import roomescape.system.dto.response.RoomEscapeApiResponse;
|
||||||
|
import roomescape.system.exception.RoomEscapeException;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@Tag(name = "3. 예약 API", description = "예약 및 대기 정보를 추가 / 조회 / 삭제할 때 사용합니다.")
|
||||||
|
public class ReservationController {
|
||||||
|
|
||||||
|
private final ReservationWithPaymentService reservationWithPaymentService;
|
||||||
|
private final ReservationService reservationService;
|
||||||
|
private final TossPaymentClient paymentClient;
|
||||||
|
|
||||||
|
public ReservationController(ReservationWithPaymentService reservationWithPaymentService,
|
||||||
|
ReservationService reservationService, TossPaymentClient paymentClient) {
|
||||||
|
this.reservationWithPaymentService = reservationWithPaymentService;
|
||||||
|
this.reservationService = reservationService;
|
||||||
|
this.paymentClient = paymentClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Admin
|
||||||
|
@GetMapping("/reservations")
|
||||||
|
@ResponseStatus(HttpStatus.OK)
|
||||||
|
@Operation(summary = "모든 예약 정보 조회", tags = "관리자 로그인이 필요한 API")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)
|
||||||
|
})
|
||||||
|
public RoomEscapeApiResponse<ReservationsResponse> getAllReservations() {
|
||||||
|
return RoomEscapeApiResponse.success(reservationService.findAllReservations());
|
||||||
|
}
|
||||||
|
|
||||||
|
@LoginRequired
|
||||||
|
@GetMapping("/reservations-mine")
|
||||||
|
@ResponseStatus(HttpStatus.OK)
|
||||||
|
@Operation(summary = "자신의 예약 및 대기 조회", tags = "로그인이 필요한 API")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)
|
||||||
|
})
|
||||||
|
public RoomEscapeApiResponse<MyReservationsResponse> getMemberReservations(
|
||||||
|
@MemberId @Parameter(hidden = true) Long memberId) {
|
||||||
|
return RoomEscapeApiResponse.success(reservationService.findMemberReservations(memberId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Admin
|
||||||
|
@GetMapping("/reservations/search")
|
||||||
|
@ResponseStatus(HttpStatus.OK)
|
||||||
|
@Operation(summary = "관리자의 예약 검색", description = "특정 조건에 해당되는 예약 검색", tags = "관리자 로그인이 필요한 API")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true),
|
||||||
|
@ApiResponse(responseCode = "400", description = "날짜 범위를 지정할 때, 종료 날짜는 시작 날짜 이전일 수 없습니다.",
|
||||||
|
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||||
|
})
|
||||||
|
public RoomEscapeApiResponse<ReservationsResponse> getReservationBySearching(
|
||||||
|
@RequestParam(required = false) @Parameter(description = "테마 ID") Long themeId,
|
||||||
|
@RequestParam(required = false) @Parameter(description = "회원 ID") Long memberId,
|
||||||
|
@RequestParam(required = false) @Parameter(description = "yyyy-MM-dd 형식으로 입력해주세요", example = "2024-06-10") LocalDate dateFrom,
|
||||||
|
@RequestParam(required = false) @Parameter(description = "yyyy-MM-dd 형식으로 입력해주세요", example = "2024-06-10") LocalDate dateTo
|
||||||
|
) {
|
||||||
|
return RoomEscapeApiResponse.success(
|
||||||
|
reservationService.findFilteredReservations(themeId, memberId, dateFrom, dateTo));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Admin
|
||||||
|
@DeleteMapping("/reservations/{id}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
@Operation(summary = "관리자의 예약 취소", tags = "관리자 로그인이 필요한 API")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "204", description = "성공"),
|
||||||
|
@ApiResponse(responseCode = "404", description = "예약 또는 결제 정보를 찾을 수 없습니다.",
|
||||||
|
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
|
||||||
|
})
|
||||||
|
public RoomEscapeApiResponse<Void> removeReservation(
|
||||||
|
@MemberId @Parameter(hidden = true) Long memberId,
|
||||||
|
@NotNull(message = "reservationId는 null일 수 없습니다.") @PathVariable("id") @Parameter(description = "예약 ID") Long reservationId
|
||||||
|
) {
|
||||||
|
|
||||||
|
if (reservationWithPaymentService.isNotPaidReservation(reservationId)) {
|
||||||
|
reservationService.removeReservationById(reservationId, memberId);
|
||||||
|
return RoomEscapeApiResponse.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
PaymentCancelRequest paymentCancelRequest = reservationWithPaymentService.removeReservationWithPayment(
|
||||||
|
reservationId, memberId);
|
||||||
|
|
||||||
|
PaymentCancelResponse paymentCancelResponse = paymentClient.cancelPayment(paymentCancelRequest);
|
||||||
|
|
||||||
|
reservationWithPaymentService.updateCanceledTime(paymentCancelRequest.paymentKey(),
|
||||||
|
paymentCancelResponse.canceledAt());
|
||||||
|
|
||||||
|
return RoomEscapeApiResponse.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
@LoginRequired
|
||||||
|
@PostMapping("/reservations")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
@Operation(summary = "예약 추가", tags = "로그인이 필요한 API")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true,
|
||||||
|
headers = @Header(name = HttpHeaders.LOCATION, description = "생성된 예약 정보 URL", schema = @Schema(example = "/reservations/1")))
|
||||||
|
})
|
||||||
|
public RoomEscapeApiResponse<ReservationResponse> saveReservation(
|
||||||
|
@Valid @RequestBody ReservationRequest reservationRequest,
|
||||||
|
@MemberId @Parameter(hidden = true) Long memberId,
|
||||||
|
HttpServletResponse response
|
||||||
|
) {
|
||||||
|
PaymentRequest paymentRequest = reservationRequest.getPaymentRequest();
|
||||||
|
PaymentResponse paymentResponse = paymentClient.confirmPayment(paymentRequest);
|
||||||
|
|
||||||
|
try {
|
||||||
|
ReservationResponse reservationResponse = reservationWithPaymentService.addReservationWithPayment(
|
||||||
|
reservationRequest, paymentResponse, memberId);
|
||||||
|
return getCreatedReservationResponse(reservationResponse, response);
|
||||||
|
} catch (RoomEscapeException e) {
|
||||||
|
PaymentCancelRequest cancelRequest = new PaymentCancelRequest(paymentRequest.paymentKey(),
|
||||||
|
paymentRequest.amount(), e.getMessage());
|
||||||
|
|
||||||
|
PaymentCancelResponse paymentCancelResponse = paymentClient.cancelPayment(cancelRequest);
|
||||||
|
|
||||||
|
reservationWithPaymentService.saveCanceledPayment(paymentCancelResponse, paymentResponse.approvedAt(),
|
||||||
|
paymentRequest.paymentKey());
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Admin
|
||||||
|
@PostMapping("/reservations/admin")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
@Operation(summary = "관리자 예약 추가", tags = "관리자 로그인이 필요한 API")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true,
|
||||||
|
headers = @Header(name = HttpHeaders.LOCATION, description = "생성된 예약 정보 URL", schema = @Schema(example = "/reservations/1"))),
|
||||||
|
@ApiResponse(responseCode = "409", description = "예약이 이미 존재합니다.", content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||||
|
})
|
||||||
|
public RoomEscapeApiResponse<ReservationResponse> saveReservationByAdmin(
|
||||||
|
@Valid @RequestBody AdminReservationRequest adminReservationRequest,
|
||||||
|
HttpServletResponse response
|
||||||
|
) {
|
||||||
|
ReservationResponse reservationResponse = reservationService.addReservationByAdmin(adminReservationRequest);
|
||||||
|
return getCreatedReservationResponse(reservationResponse, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Admin
|
||||||
|
@GetMapping("/reservations/waiting")
|
||||||
|
@ResponseStatus(HttpStatus.OK)
|
||||||
|
@Operation(summary = "모든 예약 대기 조회", tags = "관리자 로그인이 필요한 API")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)
|
||||||
|
})
|
||||||
|
public RoomEscapeApiResponse<ReservationsResponse> getAllWaiting() {
|
||||||
|
return RoomEscapeApiResponse.success(reservationService.findAllWaiting());
|
||||||
|
}
|
||||||
|
|
||||||
|
@LoginRequired
|
||||||
|
@PostMapping("/reservations/waiting")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
@Operation(summary = "예약 대기 신청", tags = "로그인이 필요한 API")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true,
|
||||||
|
headers = @Header(name = HttpHeaders.LOCATION, description = "생성된 예약 정보 URL", schema = @Schema(example = "/reservations/1")))
|
||||||
|
})
|
||||||
|
public RoomEscapeApiResponse<ReservationResponse> saveWaiting(
|
||||||
|
@Valid @RequestBody WaitingRequest waitingRequest,
|
||||||
|
@MemberId @Parameter(hidden = true) Long memberId,
|
||||||
|
HttpServletResponse response
|
||||||
|
) {
|
||||||
|
ReservationResponse reservationResponse = reservationService.addWaiting(waitingRequest, memberId);
|
||||||
|
return getCreatedReservationResponse(reservationResponse, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@LoginRequired
|
||||||
|
@DeleteMapping("/reservations/waiting/{id}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
@Operation(summary = "예약 대기 취소", tags = "로그인이 필요한 API")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "204", description = "성공"),
|
||||||
|
@ApiResponse(responseCode = "404", description = "회원의 예약 대기 정보를 찾을 수 없습니다.",
|
||||||
|
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||||
|
})
|
||||||
|
public RoomEscapeApiResponse<Void> deleteWaiting(
|
||||||
|
@MemberId @Parameter(hidden = true) Long memberId,
|
||||||
|
@NotNull(message = "reservationId는 null 또는 공백일 수 없습니다.") @PathVariable("id") @Parameter(description = "예약 ID") Long reservationId
|
||||||
|
) {
|
||||||
|
reservationService.cancelWaiting(reservationId, memberId);
|
||||||
|
return RoomEscapeApiResponse.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Admin
|
||||||
|
@PostMapping("/reservations/waiting/{id}/approve")
|
||||||
|
@ResponseStatus(HttpStatus.OK)
|
||||||
|
@Operation(summary = "대기 중인 예약 승인", tags = "관리자 로그인이 필요한 API")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "성공"),
|
||||||
|
@ApiResponse(responseCode = "404", description = "예약 대기 정보를 찾을 수 없습니다.",
|
||||||
|
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
|
||||||
|
@ApiResponse(responseCode = "409", description = "확정된 예약이 존재하여 대기 중인 예약을 승인할 수 없습니다.",
|
||||||
|
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||||
|
})
|
||||||
|
public RoomEscapeApiResponse<Void> approveWaiting(
|
||||||
|
@MemberId @Parameter(hidden = true) Long memberId,
|
||||||
|
@NotNull(message = "reservationId는 null 또는 공백일 수 없습니다.") @PathVariable("id") @Parameter(description = "예약 ID") Long reservationId
|
||||||
|
) {
|
||||||
|
reservationService.approveWaiting(reservationId, memberId);
|
||||||
|
|
||||||
|
return RoomEscapeApiResponse.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Admin
|
||||||
|
@PostMapping("/reservations/waiting/{id}/deny")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
@Operation(summary = "대기 중인 예약 거절", tags = "관리자 로그인이 필요한 API")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "204", description = "대기 중인 예약 거절 성공"),
|
||||||
|
@ApiResponse(responseCode = "404", description = "예약 대기 정보를 찾을 수 없습니다.",
|
||||||
|
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||||
|
})
|
||||||
|
public RoomEscapeApiResponse<Void> denyWaiting(
|
||||||
|
@MemberId @Parameter(hidden = true) Long memberId,
|
||||||
|
@NotNull(message = "reservationId는 null 또는 공백일 수 없습니다.") @PathVariable("id") @Parameter(description = "예약 ID") Long reservationId
|
||||||
|
) {
|
||||||
|
reservationService.denyWaiting(reservationId, memberId);
|
||||||
|
|
||||||
|
return RoomEscapeApiResponse.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
private RoomEscapeApiResponse<ReservationResponse> getCreatedReservationResponse(
|
||||||
|
ReservationResponse reservationResponse,
|
||||||
|
HttpServletResponse response
|
||||||
|
) {
|
||||||
|
response.setHeader(HttpHeaders.LOCATION, "/reservations/" + reservationResponse.id());
|
||||||
|
return RoomEscapeApiResponse.success(reservationResponse);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,112 @@
|
|||||||
|
package roomescape.reservation.controller;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import roomescape.reservation.dto.request.ReservationTimeRequest;
|
||||||
|
import roomescape.reservation.dto.response.ReservationTimeInfosResponse;
|
||||||
|
import roomescape.reservation.dto.response.ReservationTimeResponse;
|
||||||
|
import roomescape.reservation.dto.response.ReservationTimesResponse;
|
||||||
|
import roomescape.reservation.service.ReservationTimeService;
|
||||||
|
import roomescape.system.auth.annotation.Admin;
|
||||||
|
import roomescape.system.auth.annotation.LoginRequired;
|
||||||
|
import roomescape.system.dto.response.ErrorResponse;
|
||||||
|
import roomescape.system.dto.response.RoomEscapeApiResponse;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@Tag(name = "4. 예약 시간 API", description = "예약 시간을 조회 / 추가 / 삭제할 때 사용합니다.")
|
||||||
|
public class ReservationTimeController {
|
||||||
|
|
||||||
|
private final ReservationTimeService reservationTimeService;
|
||||||
|
|
||||||
|
public ReservationTimeController(ReservationTimeService reservationTimeService) {
|
||||||
|
this.reservationTimeService = reservationTimeService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Admin
|
||||||
|
@GetMapping("/times")
|
||||||
|
@ResponseStatus(HttpStatus.OK)
|
||||||
|
@Operation(summary = "모든 시간 조회", tags = "관리자 로그인이 필요한 API")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)
|
||||||
|
})
|
||||||
|
public RoomEscapeApiResponse<ReservationTimesResponse> getAllTimes() {
|
||||||
|
return RoomEscapeApiResponse.success(reservationTimeService.findAllTimes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Admin
|
||||||
|
@PostMapping("/times")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
@Operation(summary = "시간 추가", tags = "관리자 로그인이 필요한 API")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true),
|
||||||
|
@ApiResponse(responseCode = "409", description = "같은 시간을 추가할 수 없습니다.",
|
||||||
|
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||||
|
})
|
||||||
|
public RoomEscapeApiResponse<ReservationTimeResponse> saveTime(
|
||||||
|
@Valid @RequestBody ReservationTimeRequest reservationTimeRequest,
|
||||||
|
HttpServletResponse response
|
||||||
|
) {
|
||||||
|
ReservationTimeResponse reservationTimeResponse = reservationTimeService.addTime(reservationTimeRequest);
|
||||||
|
response.setHeader(HttpHeaders.LOCATION, "/times/" + reservationTimeResponse.id());
|
||||||
|
|
||||||
|
return RoomEscapeApiResponse.success(reservationTimeResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Admin
|
||||||
|
@DeleteMapping("/times/{id}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
@Operation(summary = "시간 삭제", tags = "관리자 로그인이 필요한 API")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true),
|
||||||
|
@ApiResponse(responseCode = "409", description = "예약된 시간은 삭제할 수 없습니다.",
|
||||||
|
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||||
|
})
|
||||||
|
public RoomEscapeApiResponse<Void> removeTime(
|
||||||
|
@NotNull(message = "timeId는 null 또는 공백일 수 없습니다.") @PathVariable @Parameter(description = "삭제하고자 하는 시간의 ID값") Long id
|
||||||
|
) {
|
||||||
|
reservationTimeService.removeTimeById(id);
|
||||||
|
|
||||||
|
return RoomEscapeApiResponse.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
@LoginRequired
|
||||||
|
@GetMapping("/times/filter")
|
||||||
|
@ResponseStatus(HttpStatus.OK)
|
||||||
|
@Operation(summary = "예약 가능 여부를 포함한 모든 시간 조회", tags = "로그인이 필요한 API")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)
|
||||||
|
})
|
||||||
|
public RoomEscapeApiResponse<ReservationTimeInfosResponse> findAllAvailableReservationTimes(
|
||||||
|
@NotNull(message = "날짜는 null일 수 없습니다.")
|
||||||
|
@RequestParam
|
||||||
|
@Parameter(description = "yyyy-MM-dd 형식으로 입력해주세요.", example = "2024-06-10")
|
||||||
|
LocalDate date,
|
||||||
|
@NotNull(message = "themeId는 null일 수 없습니다.")
|
||||||
|
@RequestParam
|
||||||
|
@Parameter(description = "조회할 테마의 ID를 입력해주세요.", example = "1")
|
||||||
|
Long themeId
|
||||||
|
) {
|
||||||
|
return RoomEscapeApiResponse.success(reservationTimeService.findAllAvailableTimesByDateAndTheme(date, themeId));
|
||||||
|
}
|
||||||
|
}
|
||||||
127
src/main/java/roomescape/reservation/domain/Reservation.java
Normal file
127
src/main/java/roomescape/reservation/domain/Reservation.java
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
package roomescape.reservation.domain;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import roomescape.member.domain.Member;
|
||||||
|
import roomescape.system.exception.ErrorType;
|
||||||
|
import roomescape.system.exception.RoomEscapeException;
|
||||||
|
import roomescape.theme.domain.Theme;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
public class Reservation {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private LocalDate date;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "time_id", nullable = false)
|
||||||
|
private ReservationTime reservationTime;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "theme_id", nullable = false)
|
||||||
|
private Theme theme;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
|
@JoinColumn(name = "member_id", nullable = false)
|
||||||
|
private Member member;
|
||||||
|
|
||||||
|
@Enumerated(value = EnumType.STRING)
|
||||||
|
private ReservationStatus reservationStatus;
|
||||||
|
|
||||||
|
protected Reservation() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Reservation(
|
||||||
|
LocalDate date,
|
||||||
|
ReservationTime reservationTime,
|
||||||
|
Theme theme,
|
||||||
|
Member member,
|
||||||
|
ReservationStatus status
|
||||||
|
) {
|
||||||
|
this(null, date, reservationTime, theme, member, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Reservation(
|
||||||
|
Long id,
|
||||||
|
LocalDate date,
|
||||||
|
ReservationTime reservationTime,
|
||||||
|
Theme theme,
|
||||||
|
Member member,
|
||||||
|
ReservationStatus status
|
||||||
|
) {
|
||||||
|
validateIsNull(date, reservationTime, theme, member, status);
|
||||||
|
this.id = id;
|
||||||
|
this.date = date;
|
||||||
|
this.reservationTime = reservationTime;
|
||||||
|
this.theme = theme;
|
||||||
|
this.member = member;
|
||||||
|
this.reservationStatus = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateIsNull(LocalDate date, ReservationTime reservationTime, Theme theme, Member member,
|
||||||
|
ReservationStatus reservationStatus) {
|
||||||
|
if (date == null || reservationTime == null || theme == null || member == null || reservationStatus == null) {
|
||||||
|
throw new RoomEscapeException(ErrorType.REQUEST_DATA_BLANK, String.format("[values: %s]", this),
|
||||||
|
HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getMemberId() {
|
||||||
|
return member.getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getDate() {
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReservationTime getReservationTime() {
|
||||||
|
return reservationTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Theme getTheme() {
|
||||||
|
return theme;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Member getMember() {
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReservationStatus getReservationStatus() {
|
||||||
|
return reservationStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
public boolean isSameDateAndTime(LocalDate date, ReservationTime time) {
|
||||||
|
return this.date.equals(date) && time.getStartAt().equals(this.reservationTime.getStartAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
public boolean isWaiting() {
|
||||||
|
return reservationStatus == ReservationStatus.WAITING;
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
public boolean isSameMember(Long memberId) {
|
||||||
|
return getMemberId().equals(memberId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package roomescape.reservation.domain;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
@Schema(description = "예약 상태를 나타냅니다.", allowableValues = {"CONFIRMED", "CONFIRMED_PAYMENT_REQUIRED", "WAITING"})
|
||||||
|
public enum ReservationStatus {
|
||||||
|
@Schema(description = "결제가 완료된 예약")
|
||||||
|
CONFIRMED,
|
||||||
|
@Schema(description = "결제가 필요한 예약")
|
||||||
|
CONFIRMED_PAYMENT_REQUIRED,
|
||||||
|
@Schema(description = "대기 중인 예약")
|
||||||
|
WAITING;
|
||||||
|
}
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
package roomescape.reservation.domain;
|
||||||
|
|
||||||
|
import java.time.LocalTime;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import roomescape.system.exception.ErrorType;
|
||||||
|
import roomescape.system.exception.RoomEscapeException;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
public class ReservationTime {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private LocalTime startAt;
|
||||||
|
|
||||||
|
protected ReservationTime() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReservationTime(final LocalTime startAt) {
|
||||||
|
this(null, startAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReservationTime(final Long id, final LocalTime startAt) {
|
||||||
|
this.id = id;
|
||||||
|
this.startAt = startAt;
|
||||||
|
|
||||||
|
validateNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateNull() {
|
||||||
|
if (startAt == null) {
|
||||||
|
throw new RoomEscapeException(ErrorType.REQUEST_DATA_BLANK, String.format("[values: %s]", this),
|
||||||
|
HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalTime getStartAt() {
|
||||||
|
return startAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "ReservationTime{" +
|
||||||
|
"id=" + id +
|
||||||
|
", startAt=" + startAt +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
package roomescape.reservation.domain.repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
import roomescape.reservation.domain.Reservation;
|
||||||
|
import roomescape.reservation.domain.ReservationStatus;
|
||||||
|
import roomescape.reservation.domain.ReservationTime;
|
||||||
|
import roomescape.reservation.dto.response.MyReservationResponse;
|
||||||
|
|
||||||
|
public interface ReservationRepository extends JpaRepository<Reservation, Long>, JpaSpecificationExecutor<Reservation> {
|
||||||
|
|
||||||
|
List<Reservation> findByReservationTime(ReservationTime reservationTime);
|
||||||
|
|
||||||
|
List<Reservation> findByThemeId(Long themeId);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query("""
|
||||||
|
UPDATE Reservation r
|
||||||
|
SET r.reservationStatus = :status
|
||||||
|
WHERE r.id = :id
|
||||||
|
""")
|
||||||
|
int updateStatusByReservationId(@Param(value = "id") Long reservationId,
|
||||||
|
@Param(value = "status") ReservationStatus statusForChange);
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT 1 FROM Reservation r
|
||||||
|
WHERE r.theme.id = r2.theme.id
|
||||||
|
AND r.reservationTime.id = r2.reservationTime.id
|
||||||
|
AND r.date = r2.date
|
||||||
|
AND r.reservationStatus != 'WAITING'
|
||||||
|
)
|
||||||
|
FROM Reservation r2
|
||||||
|
WHERE r2.id = :id
|
||||||
|
""")
|
||||||
|
boolean isExistConfirmedReservation(@Param("id") Long reservationId);
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT new roomescape.reservation.dto.response.MyReservationResponse(
|
||||||
|
r.id,
|
||||||
|
t.name,
|
||||||
|
r.date,
|
||||||
|
r.reservationTime.startAt,
|
||||||
|
r.reservationStatus,
|
||||||
|
(SELECT COUNT (r2) FROM Reservation r2 WHERE r2.theme = r.theme AND r2.date = r.date AND r2.reservationTime = r.reservationTime AND r2.id < r.id),
|
||||||
|
p.paymentKey,
|
||||||
|
p.totalAmount
|
||||||
|
)
|
||||||
|
FROM Reservation r
|
||||||
|
JOIN r.theme t
|
||||||
|
LEFT JOIN Payment p
|
||||||
|
ON p.reservation = r
|
||||||
|
WHERE r.member.id = :memberId
|
||||||
|
""")
|
||||||
|
List<MyReservationResponse> findMyReservations(Long memberId);
|
||||||
|
}
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
package roomescape.reservation.domain.repository;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
|
||||||
|
import roomescape.reservation.domain.Reservation;
|
||||||
|
import roomescape.reservation.domain.ReservationStatus;
|
||||||
|
|
||||||
|
public class ReservationSearchSpecification {
|
||||||
|
|
||||||
|
private Specification<Reservation> spec;
|
||||||
|
|
||||||
|
public ReservationSearchSpecification() {
|
||||||
|
this.spec = Specification.where(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReservationSearchSpecification sameThemeId(Long themeId) {
|
||||||
|
if (themeId != null) {
|
||||||
|
this.spec = this.spec.and(
|
||||||
|
(root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("theme").get("id"), themeId));
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReservationSearchSpecification sameMemberId(Long memberId) {
|
||||||
|
if (memberId != null) {
|
||||||
|
this.spec = this.spec.and(
|
||||||
|
(root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("member").get("id"), memberId));
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReservationSearchSpecification sameTimeId(Long timeId) {
|
||||||
|
if (timeId != null) {
|
||||||
|
this.spec = this.spec.and(
|
||||||
|
(root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("reservationTime").get("id"),
|
||||||
|
timeId));
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReservationSearchSpecification sameDate(LocalDate date) {
|
||||||
|
if (date != null) {
|
||||||
|
this.spec = this.spec.and(
|
||||||
|
(root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("date"), date));
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReservationSearchSpecification confirmed() {
|
||||||
|
this.spec = this.spec.and(
|
||||||
|
(root, query, criteriaBuilder) -> criteriaBuilder.or(
|
||||||
|
criteriaBuilder.equal(root.get("reservationStatus"), ReservationStatus.CONFIRMED),
|
||||||
|
criteriaBuilder.equal(root.get("reservationStatus"),
|
||||||
|
ReservationStatus.CONFIRMED_PAYMENT_REQUIRED)
|
||||||
|
));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReservationSearchSpecification waiting() {
|
||||||
|
this.spec = this.spec.and(
|
||||||
|
(root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("reservationStatus"),
|
||||||
|
ReservationStatus.WAITING));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReservationSearchSpecification dateStartFrom(LocalDate dateFrom) {
|
||||||
|
if (dateFrom != null) {
|
||||||
|
this.spec = this.spec.and(
|
||||||
|
(root, query, criteriaBuilder) -> criteriaBuilder.greaterThanOrEqualTo(root.get("date"), dateFrom));
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReservationSearchSpecification dateEndAt(LocalDate toDate) {
|
||||||
|
if (toDate != null) {
|
||||||
|
this.spec = this.spec.and(
|
||||||
|
(root, query, criteriaBuilder) -> criteriaBuilder.lessThanOrEqualTo(root.get("date"), toDate));
|
||||||
|
}
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Specification<Reservation> build() {
|
||||||
|
return this.spec;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package roomescape.reservation.domain.repository;
|
||||||
|
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import roomescape.reservation.domain.ReservationTime;
|
||||||
|
|
||||||
|
public interface ReservationTimeRepository extends JpaRepository<ReservationTime, Long> {
|
||||||
|
|
||||||
|
List<ReservationTime> findByStartAt(LocalTime startAt);
|
||||||
|
}
|
||||||
@ -0,0 +1,18 @@
|
|||||||
|
package roomescape.reservation.dto.request;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
@Schema(name = "관리자 예약 저장 요청", description = "관리자의 예약 저장 요청시 사용됩니다.")
|
||||||
|
public record AdminReservationRequest(
|
||||||
|
@Schema(description = "예약 날짜. 지난 날짜는 지정할 수 없으며, yyyy-MM-dd 형식으로 입력해야 합니다.", type = "string", example = "2022-12-31")
|
||||||
|
LocalDate date,
|
||||||
|
@Schema(description = "예약 시간 ID.", example = "1")
|
||||||
|
Long timeId,
|
||||||
|
@Schema(description = "테마 ID", example = "1")
|
||||||
|
Long themeId,
|
||||||
|
@Schema(description = "회원 ID", example = "1")
|
||||||
|
Long memberId
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
package roomescape.reservation.dto.request;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import roomescape.payment.dto.request.PaymentRequest;
|
||||||
|
|
||||||
|
@Schema(name = "회원의 예약 저장 요청", description = "회원의 예약 요청시 사용됩니다.")
|
||||||
|
public record ReservationRequest(
|
||||||
|
@NotNull(message = "예약 날짜는 null일 수 없습니다.")
|
||||||
|
@Schema(description = "예약 날짜. 지난 날짜는 지정할 수 없으며, yyyy-MM-dd 형식으로 입력해야 합니다.", type = "string", example = "2022-12-31")
|
||||||
|
LocalDate date,
|
||||||
|
@NotNull(message = "예약 요청의 timeId는 null일 수 없습니다.")
|
||||||
|
@Schema(description = "예약 시간 ID.", example = "1")
|
||||||
|
Long timeId,
|
||||||
|
@NotNull(message = "예약 요청의 themeId는 null일 수 없습니다.")
|
||||||
|
@Schema(description = "테마 ID", example = "1")
|
||||||
|
Long themeId,
|
||||||
|
@Schema(description = "결제 위젯을 통해 받은 결제 키")
|
||||||
|
String paymentKey,
|
||||||
|
@Schema(description = "결제 위젯을 통해 받은 주문번호.")
|
||||||
|
String orderId,
|
||||||
|
@Schema(description = "결제 위젯을 통해 받은 결제 금액")
|
||||||
|
Long amount,
|
||||||
|
@Schema(description = "결제 타입", example = "NORMAL")
|
||||||
|
String paymentType
|
||||||
|
) {
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
public PaymentRequest getPaymentRequest() {
|
||||||
|
return new PaymentRequest(paymentKey, orderId, amount, paymentType);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package roomescape.reservation.dto.request;
|
||||||
|
|
||||||
|
import java.time.LocalTime;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
||||||
|
import io.micrometer.common.util.StringUtils;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import roomescape.reservation.domain.ReservationTime;
|
||||||
|
import roomescape.system.exception.ErrorType;
|
||||||
|
import roomescape.system.exception.RoomEscapeException;
|
||||||
|
|
||||||
|
@Schema(name = "예약 시간 저장 요청", description = "예약 시간 저장 요청시 사용됩니다.")
|
||||||
|
public record ReservationTimeRequest(
|
||||||
|
@NotNull(message = "예약 시간은 null일 수 없습니다.")
|
||||||
|
@Schema(description = "예약 시간. HH:mm 형식으로 입력해야 합니다.", type = "string", example = "09:00")
|
||||||
|
LocalTime startAt
|
||||||
|
) {
|
||||||
|
|
||||||
|
public ReservationTimeRequest {
|
||||||
|
if (StringUtils.isBlank(startAt.toString())) {
|
||||||
|
throw new RoomEscapeException(ErrorType.REQUEST_DATA_BLANK,
|
||||||
|
String.format("[values: %s]", this), HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReservationTime toTime() {
|
||||||
|
return new ReservationTime(this.startAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package roomescape.reservation.dto.request;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
@Schema(name = "예약 대기 저장 요청", description = "회원의 예약 대기 요청시 사용됩니다.")
|
||||||
|
public record WaitingRequest(
|
||||||
|
@NotNull(message = "예약 날짜는 null일 수 없습니다.")
|
||||||
|
@Schema(description = "예약 날짜. 지난 날짜는 지정할 수 없으며, yyyy-MM-dd 형식으로 입력해야 합니다.", type = "string", example = "2022-12-31")
|
||||||
|
LocalDate date,
|
||||||
|
@NotNull(message = "예약 요청의 timeId는 null일 수 없습니다.")
|
||||||
|
@Schema(description = "예약 시간 ID", example = "1")
|
||||||
|
Long timeId,
|
||||||
|
@NotNull(message = "예약 요청의 themeId는 null일 수 없습니다.")
|
||||||
|
@Schema(description = "테마 ID", example = "1")
|
||||||
|
Long themeId
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package roomescape.reservation.dto.response;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import roomescape.reservation.domain.ReservationStatus;
|
||||||
|
|
||||||
|
@Schema(name = "회원의 예약 및 대기 응답", description = "회원의 예약 및 대기 정보 응답시 사용됩니다.")
|
||||||
|
public record MyReservationResponse(
|
||||||
|
@Schema(description = "예약 번호. 예약을 식별할 때 사용합니다.")
|
||||||
|
Long id,
|
||||||
|
@Schema(description = "테마 이름")
|
||||||
|
String themeName,
|
||||||
|
@Schema(description = "예약 날짜", type = "string", example = "2022-12-31")
|
||||||
|
LocalDate date,
|
||||||
|
@Schema(description = "예약 시간", type = "string", example = "09:00")
|
||||||
|
LocalTime time,
|
||||||
|
@Schema(description = "예약 상태", type = "string")
|
||||||
|
ReservationStatus status,
|
||||||
|
@Schema(description = "예약 대기 상태일 때의 대기 순번. 확정된 예약은 0의 값을 가집니다.")
|
||||||
|
Long rank,
|
||||||
|
@Schema(description = "결제 키. 결제가 완료된 예약에만 값이 존재합니다.")
|
||||||
|
String paymentKey,
|
||||||
|
@Schema(description = "결제 금액. 결제가 완료된 예약에만 값이 존재합니다.")
|
||||||
|
Long amount
|
||||||
|
) {
|
||||||
|
|
||||||
|
public MyReservationResponse(Long id, String themeName, LocalDate date, LocalTime time, ReservationStatus status,
|
||||||
|
Integer rank, String paymentKey, Long amount) {
|
||||||
|
this(id, themeName, date, time, status, rank.longValue(), paymentKey, amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package roomescape.reservation.dto.response;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
@Schema(name = "회원의 예약 및 대기 목록 조회 응답", description = "회원의 예약 및 대기 목록 조회 응답시 사용됩니다.")
|
||||||
|
public record MyReservationsResponse(
|
||||||
|
@Schema(description = "현재 로그인한 회원의 예약 및 대기 목록") List<MyReservationResponse> myReservationResponses
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,42 @@
|
|||||||
|
package roomescape.reservation.dto.response;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import roomescape.member.dto.MemberResponse;
|
||||||
|
import roomescape.reservation.domain.Reservation;
|
||||||
|
import roomescape.reservation.domain.ReservationStatus;
|
||||||
|
import roomescape.theme.dto.ThemeResponse;
|
||||||
|
|
||||||
|
@Schema(name = "예약 정보", description = "예약 저장 및 조회 응답에 사용됩니다.")
|
||||||
|
public record ReservationResponse(
|
||||||
|
@Schema(description = "예약 번호. 예약을 식별할 때 사용합니다.")
|
||||||
|
Long id,
|
||||||
|
@Schema(description = "예약 날짜", type = "string", example = "2022-12-31")
|
||||||
|
LocalDate date,
|
||||||
|
@JsonProperty("member")
|
||||||
|
@Schema(description = "예약한 회원 정보")
|
||||||
|
MemberResponse member,
|
||||||
|
@JsonProperty("time")
|
||||||
|
@Schema(description = "예약 시간 정보")
|
||||||
|
ReservationTimeResponse time,
|
||||||
|
@JsonProperty("theme")
|
||||||
|
@Schema(description = "예약한 테마 정보")
|
||||||
|
ThemeResponse theme,
|
||||||
|
@Schema(description = "예약 상태", type = "string")
|
||||||
|
ReservationStatus status
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static ReservationResponse from(Reservation reservation) {
|
||||||
|
return new ReservationResponse(
|
||||||
|
reservation.getId(),
|
||||||
|
reservation.getDate(),
|
||||||
|
MemberResponse.fromEntity(reservation.getMember()),
|
||||||
|
ReservationTimeResponse.from(reservation.getReservationTime()),
|
||||||
|
ThemeResponse.from(reservation.getTheme()),
|
||||||
|
reservation.getReservationStatus()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package roomescape.reservation.dto.response;
|
||||||
|
|
||||||
|
import java.time.LocalTime;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
@Schema(name = "특정 테마, 날짜에 대한 시간 정보 응답", description = "특정 날짜와 테마에 대해, 예약 가능 여부를 포함한 시간 정보를 저장합니다.")
|
||||||
|
public record ReservationTimeInfoResponse(
|
||||||
|
@Schema(description = "예약 시간 번호. 예약 시간을 식별할 때 사용합니다.")
|
||||||
|
Long timeId,
|
||||||
|
@Schema(description = "예약 시간", type = "string", example = "09:00")
|
||||||
|
LocalTime startAt,
|
||||||
|
@Schema(description = "이미 예약이 완료된 시간인지 여부")
|
||||||
|
boolean alreadyBooked
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package roomescape.reservation.dto.response;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
@Schema(name = "예약 시간 정보 목록 응답", description = "특정 테마, 날짜에 대한 모든 예약 가능 시간 정보를 저장합니다.")
|
||||||
|
public record ReservationTimeInfosResponse(
|
||||||
|
@Schema(description = "특정 테마, 날짜에 대한 예약 가능 여부를 포함한 시간 목록") List<ReservationTimeInfoResponse> reservationTimes
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,19 @@
|
|||||||
|
package roomescape.reservation.dto.response;
|
||||||
|
|
||||||
|
import java.time.LocalTime;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import roomescape.reservation.domain.ReservationTime;
|
||||||
|
|
||||||
|
@Schema(name = "예약 시간 정보", description = "예약 시간 추가 및 조회 응답시 사용됩니다.")
|
||||||
|
public record ReservationTimeResponse(
|
||||||
|
@Schema(description = "예약 시간 번호. 예약 시간을 식별할 때 사용합니다.")
|
||||||
|
Long id,
|
||||||
|
@Schema(description = "예약 시간", type = "string", example = "09:00")
|
||||||
|
LocalTime startAt
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static ReservationTimeResponse from(ReservationTime reservationTime) {
|
||||||
|
return new ReservationTimeResponse(reservationTime.getId(), reservationTime.getStartAt());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package roomescape.reservation.dto.response;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
@Schema(name = "예약 시간 정보 목록 응답", description = "모든 예약 시간 조회 응답시 사용됩니다.")
|
||||||
|
public record ReservationTimesResponse(
|
||||||
|
@Schema(description = "모든 시간 목록") List<ReservationTimeResponse> times
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package roomescape.reservation.dto.response;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
@Schema(name = "예약 목록 조회 응답", description = "모든 예약 정보 조회 응답시 사용됩니다.")
|
||||||
|
public record ReservationsResponse(
|
||||||
|
@Schema(description = "모든 예약 및 대기 목록") List<ReservationResponse> reservations
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,227 @@
|
|||||||
|
package roomescape.reservation.service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.domain.Specification;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import roomescape.member.domain.Member;
|
||||||
|
import roomescape.member.service.MemberService;
|
||||||
|
import roomescape.reservation.domain.Reservation;
|
||||||
|
import roomescape.reservation.domain.ReservationStatus;
|
||||||
|
import roomescape.reservation.domain.ReservationTime;
|
||||||
|
import roomescape.reservation.domain.repository.ReservationRepository;
|
||||||
|
import roomescape.reservation.domain.repository.ReservationSearchSpecification;
|
||||||
|
import roomescape.reservation.dto.request.AdminReservationRequest;
|
||||||
|
import roomescape.reservation.dto.request.ReservationRequest;
|
||||||
|
import roomescape.reservation.dto.request.WaitingRequest;
|
||||||
|
import roomescape.reservation.dto.response.MyReservationsResponse;
|
||||||
|
import roomescape.reservation.dto.response.ReservationResponse;
|
||||||
|
import roomescape.reservation.dto.response.ReservationsResponse;
|
||||||
|
import roomescape.system.exception.ErrorType;
|
||||||
|
import roomescape.system.exception.RoomEscapeException;
|
||||||
|
import roomescape.theme.domain.Theme;
|
||||||
|
import roomescape.theme.service.ThemeService;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
public class ReservationService {
|
||||||
|
|
||||||
|
private final ReservationRepository reservationRepository;
|
||||||
|
private final ReservationTimeService reservationTimeService;
|
||||||
|
private final MemberService memberService;
|
||||||
|
private final ThemeService themeService;
|
||||||
|
|
||||||
|
public ReservationService(
|
||||||
|
ReservationRepository reservationRepository,
|
||||||
|
ReservationTimeService reservationTimeService,
|
||||||
|
MemberService memberService,
|
||||||
|
ThemeService themeService
|
||||||
|
) {
|
||||||
|
this.reservationRepository = reservationRepository;
|
||||||
|
this.reservationTimeService = reservationTimeService;
|
||||||
|
this.memberService = memberService;
|
||||||
|
this.themeService = themeService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ReservationsResponse findAllReservations() {
|
||||||
|
Specification<Reservation> spec = new ReservationSearchSpecification().confirmed().build();
|
||||||
|
List<ReservationResponse> response = findAllReservationByStatus(spec);
|
||||||
|
|
||||||
|
return new ReservationsResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ReservationsResponse findAllWaiting() {
|
||||||
|
Specification<Reservation> spec = new ReservationSearchSpecification().waiting().build();
|
||||||
|
List<ReservationResponse> response = findAllReservationByStatus(spec);
|
||||||
|
|
||||||
|
return new ReservationsResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ReservationResponse> findAllReservationByStatus(Specification<Reservation> spec) {
|
||||||
|
return reservationRepository.findAll(spec)
|
||||||
|
.stream()
|
||||||
|
.map(ReservationResponse::from)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeReservationById(Long reservationId, Long memberId) {
|
||||||
|
validateIsMemberAdmin(memberId);
|
||||||
|
reservationRepository.deleteById(reservationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Reservation addReservation(ReservationRequest request, Long memberId) {
|
||||||
|
validateIsReservationExist(request.themeId(), request.timeId(), request.date());
|
||||||
|
Reservation reservation = getReservationForSave(request.timeId(), request.themeId(), request.date(), memberId,
|
||||||
|
ReservationStatus.CONFIRMED);
|
||||||
|
return reservationRepository.save(reservation);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReservationResponse addReservationByAdmin(AdminReservationRequest request) {
|
||||||
|
validateIsReservationExist(request.themeId(), request.timeId(), request.date());
|
||||||
|
return addReservationWithoutPayment(request.themeId(), request.timeId(), request.date(),
|
||||||
|
request.memberId(), ReservationStatus.CONFIRMED_PAYMENT_REQUIRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReservationResponse addWaiting(WaitingRequest request, Long memberId) {
|
||||||
|
validateMemberAlreadyReserve(request.themeId(), request.timeId(), request.date(), memberId);
|
||||||
|
return addReservationWithoutPayment(request.themeId(), request.timeId(), request.date(), memberId,
|
||||||
|
ReservationStatus.WAITING);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReservationResponse addReservationWithoutPayment(Long themeId, Long timeId, LocalDate date, Long memberId,
|
||||||
|
ReservationStatus status) {
|
||||||
|
Reservation reservation = getReservationForSave(timeId, themeId, date, memberId, status);
|
||||||
|
Reservation saved = reservationRepository.save(reservation);
|
||||||
|
return ReservationResponse.from(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateMemberAlreadyReserve(Long themeId, Long timeId, LocalDate date, Long memberId) {
|
||||||
|
Specification<Reservation> spec = new ReservationSearchSpecification()
|
||||||
|
.sameMemberId(memberId)
|
||||||
|
.sameThemeId(themeId)
|
||||||
|
.sameTimeId(timeId)
|
||||||
|
.sameDate(date)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
if (reservationRepository.exists(spec)) {
|
||||||
|
throw new RoomEscapeException(ErrorType.HAS_RESERVATION_OR_WAITING, HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateIsReservationExist(Long themeId, Long timeId, LocalDate date) {
|
||||||
|
Specification<Reservation> spec = new ReservationSearchSpecification()
|
||||||
|
.confirmed()
|
||||||
|
.sameThemeId(themeId)
|
||||||
|
.sameTimeId(timeId)
|
||||||
|
.sameDate(date)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
if (reservationRepository.exists(spec)) {
|
||||||
|
throw new RoomEscapeException(ErrorType.RESERVATION_DUPLICATED, HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateDateAndTime(
|
||||||
|
LocalDate requestDate,
|
||||||
|
ReservationTime requestReservationTime
|
||||||
|
) {
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
LocalDateTime request = LocalDateTime.of(requestDate, requestReservationTime.getStartAt());
|
||||||
|
if (request.isBefore(now)) {
|
||||||
|
throw new RoomEscapeException(ErrorType.RESERVATION_PERIOD_IN_PAST,
|
||||||
|
String.format("[now: %s %s | request: %s %s]",
|
||||||
|
now.toLocalDate(), now.toLocalTime(), requestDate, requestReservationTime.getStartAt()),
|
||||||
|
HttpStatus.BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Reservation getReservationForSave(Long timeId, Long themeId, LocalDate date, Long memberId,
|
||||||
|
ReservationStatus status) {
|
||||||
|
ReservationTime time = reservationTimeService.findTimeById(timeId);
|
||||||
|
Theme theme = themeService.findThemeById(themeId);
|
||||||
|
Member member = memberService.findMemberById(memberId);
|
||||||
|
|
||||||
|
validateDateAndTime(date, time);
|
||||||
|
return new Reservation(date, time, theme, member, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ReservationsResponse findFilteredReservations(Long themeId, Long memberId, LocalDate dateFrom,
|
||||||
|
LocalDate dateTo) {
|
||||||
|
validateDateForSearch(dateFrom, dateTo);
|
||||||
|
Specification<Reservation> spec = new ReservationSearchSpecification()
|
||||||
|
.confirmed()
|
||||||
|
.sameThemeId(themeId)
|
||||||
|
.sameMemberId(memberId)
|
||||||
|
.dateStartFrom(dateFrom)
|
||||||
|
.dateEndAt(dateTo)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
List<ReservationResponse> response = reservationRepository.findAll(spec)
|
||||||
|
.stream()
|
||||||
|
.map(ReservationResponse::from)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return new ReservationsResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateDateForSearch(LocalDate startFrom, LocalDate endAt) {
|
||||||
|
if (startFrom == null || endAt == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (startFrom.isAfter(endAt)) {
|
||||||
|
throw new RoomEscapeException(ErrorType.INVALID_DATE_RANGE,
|
||||||
|
String.format("[startFrom: %s, endAt: %s", startFrom, endAt), HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public MyReservationsResponse findMemberReservations(Long memberId) {
|
||||||
|
return new MyReservationsResponse(reservationRepository.findMyReservations(memberId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void approveWaiting(Long reservationId, Long memberId) {
|
||||||
|
validateIsMemberAdmin(memberId);
|
||||||
|
if (reservationRepository.isExistConfirmedReservation(reservationId)) {
|
||||||
|
throw new RoomEscapeException(ErrorType.RESERVATION_DUPLICATED, HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
reservationRepository.updateStatusByReservationId(reservationId, ReservationStatus.CONFIRMED_PAYMENT_REQUIRED);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void cancelWaiting(Long reservationId, Long memberId) {
|
||||||
|
Reservation waiting = reservationRepository.findById(reservationId)
|
||||||
|
.filter(Reservation::isWaiting)
|
||||||
|
.filter(r -> r.isSameMember(memberId))
|
||||||
|
.orElseThrow(() -> throwReservationNotFound(reservationId));
|
||||||
|
reservationRepository.delete(waiting);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void denyWaiting(Long reservationId, Long memberId) {
|
||||||
|
validateIsMemberAdmin(memberId);
|
||||||
|
Reservation waiting = reservationRepository.findById(reservationId)
|
||||||
|
.filter(Reservation::isWaiting)
|
||||||
|
.orElseThrow(() -> throwReservationNotFound(reservationId));
|
||||||
|
reservationRepository.delete(waiting);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateIsMemberAdmin(Long memberId) {
|
||||||
|
Member member = memberService.findMemberById(memberId);
|
||||||
|
if (member.isAdmin()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new RoomEscapeException(ErrorType.PERMISSION_DOES_NOT_EXIST, HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RoomEscapeException throwReservationNotFound(Long reservationId) {
|
||||||
|
return new RoomEscapeException(ErrorType.RESERVATION_NOT_FOUND,
|
||||||
|
String.format("[reservationId: %d]", reservationId), HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
package roomescape.reservation.service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import roomescape.reservation.domain.Reservation;
|
||||||
|
import roomescape.reservation.domain.ReservationTime;
|
||||||
|
import roomescape.reservation.domain.repository.ReservationRepository;
|
||||||
|
import roomescape.reservation.domain.repository.ReservationTimeRepository;
|
||||||
|
import roomescape.reservation.dto.request.ReservationTimeRequest;
|
||||||
|
import roomescape.reservation.dto.response.ReservationTimeInfoResponse;
|
||||||
|
import roomescape.reservation.dto.response.ReservationTimeInfosResponse;
|
||||||
|
import roomescape.reservation.dto.response.ReservationTimeResponse;
|
||||||
|
import roomescape.reservation.dto.response.ReservationTimesResponse;
|
||||||
|
import roomescape.system.exception.ErrorType;
|
||||||
|
import roomescape.system.exception.RoomEscapeException;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
public class ReservationTimeService {
|
||||||
|
|
||||||
|
private final ReservationTimeRepository reservationTimeRepository;
|
||||||
|
private final ReservationRepository reservationRepository;
|
||||||
|
|
||||||
|
public ReservationTimeService(
|
||||||
|
ReservationTimeRepository reservationTimeRepository,
|
||||||
|
ReservationRepository reservationRepository
|
||||||
|
) {
|
||||||
|
this.reservationTimeRepository = reservationTimeRepository;
|
||||||
|
this.reservationRepository = reservationRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ReservationTime findTimeById(Long id) {
|
||||||
|
return reservationTimeRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new RoomEscapeException(ErrorType.RESERVATION_TIME_NOT_FOUND,
|
||||||
|
String.format("[reservationTimeId: %d]", id), HttpStatus.BAD_REQUEST));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ReservationTimesResponse findAllTimes() {
|
||||||
|
List<ReservationTimeResponse> response = reservationTimeRepository.findAll()
|
||||||
|
.stream()
|
||||||
|
.map(ReservationTimeResponse::from)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return new ReservationTimesResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReservationTimeResponse addTime(ReservationTimeRequest reservationTimeRequest) {
|
||||||
|
validateTimeDuplication(reservationTimeRequest);
|
||||||
|
ReservationTime reservationTime = reservationTimeRepository.save(reservationTimeRequest.toTime());
|
||||||
|
|
||||||
|
return ReservationTimeResponse.from(reservationTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateTimeDuplication(ReservationTimeRequest reservationTimeRequest) {
|
||||||
|
List<ReservationTime> duplicateReservationTimes = reservationTimeRepository.findByStartAt(
|
||||||
|
reservationTimeRequest.startAt());
|
||||||
|
|
||||||
|
if (!duplicateReservationTimes.isEmpty()) {
|
||||||
|
throw new RoomEscapeException(ErrorType.TIME_DUPLICATED,
|
||||||
|
String.format("[startAt: %s]", reservationTimeRequest.startAt()), HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeTimeById(Long id) {
|
||||||
|
ReservationTime reservationTime = findTimeById(id);
|
||||||
|
List<Reservation> usingTimeReservations = reservationRepository.findByReservationTime(reservationTime);
|
||||||
|
|
||||||
|
if (!usingTimeReservations.isEmpty()) {
|
||||||
|
throw new RoomEscapeException(ErrorType.TIME_IS_USED_CONFLICT, String.format("[timeId: %d]", id),
|
||||||
|
HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
|
||||||
|
reservationTimeRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ReservationTimeInfosResponse findAllAvailableTimesByDateAndTheme(LocalDate date, Long themeId) {
|
||||||
|
List<ReservationTime> allTimes = reservationTimeRepository.findAll();
|
||||||
|
List<Reservation> reservations = reservationRepository.findByThemeId(themeId);
|
||||||
|
|
||||||
|
List<ReservationTimeInfoResponse> response = allTimes.stream()
|
||||||
|
.map(time -> new ReservationTimeInfoResponse(time.getId(), time.getStartAt(),
|
||||||
|
isReservationBooked(reservations, date, time)))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return new ReservationTimeInfosResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isReservationBooked(List<Reservation> reservations, LocalDate date, ReservationTime time) {
|
||||||
|
return reservations.stream()
|
||||||
|
.anyMatch(reservation -> reservation.isSameDateAndTime(date, time));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
package roomescape.reservation.service;
|
||||||
|
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import roomescape.payment.dto.request.PaymentCancelRequest;
|
||||||
|
import roomescape.payment.dto.response.PaymentCancelResponse;
|
||||||
|
import roomescape.payment.dto.response.PaymentResponse;
|
||||||
|
import roomescape.payment.dto.response.ReservationPaymentResponse;
|
||||||
|
import roomescape.payment.service.PaymentService;
|
||||||
|
import roomescape.reservation.domain.Reservation;
|
||||||
|
import roomescape.reservation.dto.request.ReservationRequest;
|
||||||
|
import roomescape.reservation.dto.response.ReservationResponse;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
public class ReservationWithPaymentService {
|
||||||
|
|
||||||
|
private final ReservationService reservationService;
|
||||||
|
private final PaymentService paymentService;
|
||||||
|
|
||||||
|
public ReservationWithPaymentService(ReservationService reservationService,
|
||||||
|
PaymentService paymentService) {
|
||||||
|
this.reservationService = reservationService;
|
||||||
|
this.paymentService = paymentService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ReservationResponse addReservationWithPayment(ReservationRequest request, PaymentResponse paymentInfo,
|
||||||
|
Long memberId) {
|
||||||
|
Reservation reservation = reservationService.addReservation(request, memberId);
|
||||||
|
ReservationPaymentResponse reservationPaymentResponse = paymentService.savePayment(paymentInfo, reservation);
|
||||||
|
|
||||||
|
return reservationPaymentResponse.reservation();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void saveCanceledPayment(PaymentCancelResponse cancelInfo, OffsetDateTime approvedAt, String paymentKey) {
|
||||||
|
paymentService.saveCanceledPayment(cancelInfo, approvedAt, paymentKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PaymentCancelRequest removeReservationWithPayment(Long reservationId, Long memberId) {
|
||||||
|
PaymentCancelRequest paymentCancelRequest = paymentService.cancelPaymentByAdmin(reservationId);
|
||||||
|
reservationService.removeReservationById(reservationId, memberId);
|
||||||
|
return paymentCancelRequest;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public boolean isNotPaidReservation(Long reservationId) {
|
||||||
|
return paymentService.findPaymentByReservationId(reservationId).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateCanceledTime(String paymentKey, OffsetDateTime canceledAt) {
|
||||||
|
paymentService.updateCanceledTime(paymentKey, canceledAt);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/main/java/roomescape/system/auth/annotation/Admin.java
Normal file
11
src/main/java/roomescape/system/auth/annotation/Admin.java
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package roomescape.system.auth.annotation;
|
||||||
|
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
@Target(ElementType.METHOD)
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
public @interface Admin {
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package roomescape.system.auth.annotation;
|
||||||
|
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
@Target(ElementType.METHOD)
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
public @interface LoginRequired {
|
||||||
|
}
|
||||||
@ -0,0 +1,11 @@
|
|||||||
|
package roomescape.system.auth.annotation;
|
||||||
|
|
||||||
|
import java.lang.annotation.ElementType;
|
||||||
|
import java.lang.annotation.Retention;
|
||||||
|
import java.lang.annotation.RetentionPolicy;
|
||||||
|
import java.lang.annotation.Target;
|
||||||
|
|
||||||
|
@Target(ElementType.PARAMETER)
|
||||||
|
@Retention(RetentionPolicy.RUNTIME)
|
||||||
|
public @interface MemberId {
|
||||||
|
}
|
||||||
@ -0,0 +1,102 @@
|
|||||||
|
package roomescape.system.auth.controller;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
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.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import roomescape.system.auth.annotation.LoginRequired;
|
||||||
|
import roomescape.system.auth.annotation.MemberId;
|
||||||
|
import roomescape.system.auth.dto.LoginCheckResponse;
|
||||||
|
import roomescape.system.auth.dto.LoginRequest;
|
||||||
|
import roomescape.system.auth.jwt.dto.TokenDto;
|
||||||
|
import roomescape.system.auth.service.AuthService;
|
||||||
|
import roomescape.system.dto.response.ErrorResponse;
|
||||||
|
import roomescape.system.dto.response.RoomEscapeApiResponse;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@Tag(name = "1. 인증 / 인가 API", description = "로그인, 로그아웃 및 로그인 상태를 확인합니다")
|
||||||
|
public class AuthController {
|
||||||
|
|
||||||
|
private final AuthService authService;
|
||||||
|
|
||||||
|
public AuthController(AuthService authService) {
|
||||||
|
this.authService = authService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/login")
|
||||||
|
@ResponseStatus(HttpStatus.OK)
|
||||||
|
@Operation(summary = "로그인")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "로그인 성공시 쿠키에 토큰 정보를 저장합니다."),
|
||||||
|
@ApiResponse(responseCode = "400", description = "존재하지 않는 회원이거나, 이메일 또는 비밀번호가 잘못 입력되었습니다.",
|
||||||
|
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||||
|
})
|
||||||
|
public RoomEscapeApiResponse<Void> login(
|
||||||
|
@Valid @RequestBody LoginRequest loginRequest,
|
||||||
|
HttpServletResponse response
|
||||||
|
) {
|
||||||
|
TokenDto tokenInfo = authService.login(loginRequest);
|
||||||
|
addCookieToResponse(new Cookie("accessToken", tokenInfo.accessToken()), response);
|
||||||
|
return RoomEscapeApiResponse.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/login/check")
|
||||||
|
@ResponseStatus(HttpStatus.OK)
|
||||||
|
@Operation(summary = "로그인 상태 확인")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "로그인 상태이며, 로그인된 회원의 이름을 반환합니다."),
|
||||||
|
@ApiResponse(responseCode = "400", description = "쿠키에 있는 토큰 정보로 회원을 조회할 수 없습니다.",
|
||||||
|
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
|
||||||
|
})
|
||||||
|
public RoomEscapeApiResponse<LoginCheckResponse> checkLogin(@MemberId @Parameter(hidden = true) Long memberId) {
|
||||||
|
LoginCheckResponse response = authService.checkLogin(memberId);
|
||||||
|
return RoomEscapeApiResponse.success(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@LoginRequired
|
||||||
|
@PostMapping("/logout")
|
||||||
|
@ResponseStatus(HttpStatus.OK)
|
||||||
|
@Operation(summary = "로그아웃", tags = "로그인이 필요한 API")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "로그아웃 성공시 쿠키에 저장된 토큰 정보를 삭제합니다.")
|
||||||
|
})
|
||||||
|
public RoomEscapeApiResponse<Void> logout(
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpServletResponse response
|
||||||
|
) {
|
||||||
|
Cookie cookie = getTokenCookie(request);
|
||||||
|
cookie.setValue(null);
|
||||||
|
cookie.setMaxAge(0);
|
||||||
|
addCookieToResponse(cookie, response);
|
||||||
|
return RoomEscapeApiResponse.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
private Cookie getTokenCookie(HttpServletRequest request) {
|
||||||
|
for (Cookie cookie : request.getCookies()) {
|
||||||
|
if (cookie.getName().equals("accessToken")) {
|
||||||
|
return cookie;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new Cookie("accessToken", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void addCookieToResponse(Cookie cookie, HttpServletResponse response) {
|
||||||
|
cookie.setHttpOnly(true);
|
||||||
|
|
||||||
|
response.addCookie(cookie);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
package roomescape.system.auth.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
@Schema(name = "로그인 체크 응답", description = "로그인 상태 체크 응답시 사용됩니다.")
|
||||||
|
public record LoginCheckResponse(
|
||||||
|
@Schema(description = "로그인된 회원의 이름") String name
|
||||||
|
) {
|
||||||
|
}
|
||||||
17
src/main/java/roomescape/system/auth/dto/LoginRequest.java
Normal file
17
src/main/java/roomescape/system/auth/dto/LoginRequest.java
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package roomescape.system.auth.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
|
||||||
|
@Schema(name = "로그인 요청", description = "로그인 요청 시 사용됩니다.")
|
||||||
|
public record LoginRequest(
|
||||||
|
@NotBlank(message = "이메일은 null 또는 공백일 수 없습니다.")
|
||||||
|
@Email(message = "이메일 형식이 일치하지 않습니다. 예시: abc123@gmail.com)")
|
||||||
|
@Schema(description = "필수 값이며, 이메일 형식으로 입력해야 합니다.", example = "abc123@gmail.com")
|
||||||
|
String email,
|
||||||
|
@NotBlank(message = "비밀번호는 null 또는 공백일 수 없습니다.")
|
||||||
|
@Schema(description = "최소 1글자 이상 입력해야 합니다.")
|
||||||
|
String password
|
||||||
|
) {
|
||||||
|
}
|
||||||
@ -0,0 +1,86 @@
|
|||||||
|
package roomescape.system.auth.interceptor;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.method.HandlerMethod;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import roomescape.member.domain.Member;
|
||||||
|
import roomescape.member.service.MemberService;
|
||||||
|
import roomescape.system.auth.annotation.Admin;
|
||||||
|
import roomescape.system.auth.jwt.JwtHandler;
|
||||||
|
import roomescape.system.exception.ErrorType;
|
||||||
|
import roomescape.system.exception.RoomEscapeException;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class AdminInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
private static final String ACCESS_TOKEN_COOKIE_NAME = "accessToken";
|
||||||
|
private final MemberService memberService;
|
||||||
|
private final JwtHandler jwtHandler;
|
||||||
|
|
||||||
|
public AdminInterceptor(MemberService memberService, JwtHandler jwtHandler) {
|
||||||
|
this.memberService = memberService;
|
||||||
|
this.jwtHandler = jwtHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
Object handler
|
||||||
|
)
|
||||||
|
throws Exception {
|
||||||
|
if (isHandlerIrrelevantWithAdmin(handler)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Member member;
|
||||||
|
try {
|
||||||
|
Cookie token = getToken(request);
|
||||||
|
Long memberId = jwtHandler.getMemberIdFromToken(token.getValue());
|
||||||
|
member = memberService.findMemberById(memberId);
|
||||||
|
} catch (RoomEscapeException e) {
|
||||||
|
response.sendRedirect("/login");
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (member.isAdmin()) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
response.sendRedirect("/login");
|
||||||
|
throw new RoomEscapeException(ErrorType.PERMISSION_DOES_NOT_EXIST,
|
||||||
|
String.format("[memberId: %d, Role: %s]", member.getId(), member.getRole()), HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Cookie getToken(HttpServletRequest request) {
|
||||||
|
validateCookieHeader(request);
|
||||||
|
|
||||||
|
Cookie[] cookies = request.getCookies();
|
||||||
|
return Arrays.stream(cookies)
|
||||||
|
.filter(cookie -> cookie.getName().equals(ACCESS_TOKEN_COOKIE_NAME))
|
||||||
|
.findAny()
|
||||||
|
.orElseThrow(() -> new RoomEscapeException(ErrorType.INVALID_TOKEN, HttpStatus.UNAUTHORIZED));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateCookieHeader(HttpServletRequest request) {
|
||||||
|
String cookieHeader = request.getHeader("Cookie");
|
||||||
|
if (cookieHeader == null) {
|
||||||
|
throw new RoomEscapeException(ErrorType.NOT_EXIST_COOKIE, HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isHandlerIrrelevantWithAdmin(Object handler) {
|
||||||
|
if (!(handler instanceof HandlerMethod handlerMethod)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Admin adminAnnotation = handlerMethod.getMethodAnnotation(Admin.class);
|
||||||
|
return adminAnnotation == null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,79 @@
|
|||||||
|
package roomescape.system.auth.interceptor;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.method.HandlerMethod;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import roomescape.member.domain.Member;
|
||||||
|
import roomescape.member.service.MemberService;
|
||||||
|
import roomescape.system.auth.annotation.LoginRequired;
|
||||||
|
import roomescape.system.auth.jwt.JwtHandler;
|
||||||
|
import roomescape.system.exception.ErrorType;
|
||||||
|
import roomescape.system.exception.RoomEscapeException;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class LoginInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
private static final String ACCESS_TOKEN_COOKIE_NAME = "accessToken";
|
||||||
|
private final MemberService memberService;
|
||||||
|
private final JwtHandler jwtHandler;
|
||||||
|
|
||||||
|
public LoginInterceptor(MemberService memberService, JwtHandler jwtHandler) {
|
||||||
|
this.memberService = memberService;
|
||||||
|
this.jwtHandler = jwtHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
Object handler
|
||||||
|
)
|
||||||
|
throws Exception {
|
||||||
|
if (isHandlerIrrelevantWithLoginRequired(handler)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Member member;
|
||||||
|
try {
|
||||||
|
Cookie token = getToken(request);
|
||||||
|
Long memberId = jwtHandler.getMemberIdFromToken(token.getValue());
|
||||||
|
member = memberService.findMemberById(memberId);
|
||||||
|
return member != null;
|
||||||
|
} catch (RoomEscapeException e) {
|
||||||
|
response.sendRedirect("/login");
|
||||||
|
throw new RoomEscapeException(ErrorType.LOGIN_REQUIRED, HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Cookie getToken(HttpServletRequest request) {
|
||||||
|
validateCookieHeader(request);
|
||||||
|
|
||||||
|
Cookie[] cookies = request.getCookies();
|
||||||
|
return Arrays.stream(cookies)
|
||||||
|
.filter(cookie -> cookie.getName().equals(ACCESS_TOKEN_COOKIE_NAME))
|
||||||
|
.findAny()
|
||||||
|
.orElseThrow(() -> new RoomEscapeException(ErrorType.INVALID_TOKEN, HttpStatus.UNAUTHORIZED));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateCookieHeader(HttpServletRequest request) {
|
||||||
|
String cookieHeader = request.getHeader("Cookie");
|
||||||
|
if (cookieHeader == null) {
|
||||||
|
throw new RoomEscapeException(ErrorType.NOT_EXIST_COOKIE, HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isHandlerIrrelevantWithLoginRequired(Object handler) {
|
||||||
|
if (!(handler instanceof HandlerMethod handlerMethod)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
LoginRequired loginRequiredAnnotation = handlerMethod.getMethodAnnotation(LoginRequired.class);
|
||||||
|
return loginRequiredAnnotation == null;
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/main/java/roomescape/system/auth/jwt/JwtHandler.java
Normal file
65
src/main/java/roomescape/system/auth/jwt/JwtHandler.java
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
package roomescape.system.auth.jwt;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import io.jsonwebtoken.ExpiredJwtException;
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
import io.jsonwebtoken.MalformedJwtException;
|
||||||
|
import io.jsonwebtoken.SignatureAlgorithm;
|
||||||
|
import io.jsonwebtoken.SignatureException;
|
||||||
|
import io.jsonwebtoken.UnsupportedJwtException;
|
||||||
|
import roomescape.system.auth.jwt.dto.TokenDto;
|
||||||
|
import roomescape.system.exception.ErrorType;
|
||||||
|
import roomescape.system.exception.RoomEscapeException;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class JwtHandler {
|
||||||
|
|
||||||
|
@Value("${security.jwt.token.secret-key}")
|
||||||
|
private String secretKey;
|
||||||
|
|
||||||
|
@Value("${security.jwt.token.access.expire-length}")
|
||||||
|
private long accessTokenExpireTime;
|
||||||
|
|
||||||
|
public TokenDto createToken(Long memberId) {
|
||||||
|
Date date = new Date();
|
||||||
|
Date accessTokenExpiredAt = new Date(date.getTime() + accessTokenExpireTime);
|
||||||
|
|
||||||
|
String accessToken = Jwts.builder()
|
||||||
|
.claim("memberId", memberId)
|
||||||
|
.setIssuedAt(date)
|
||||||
|
.setExpiration(accessTokenExpiredAt)
|
||||||
|
.signWith(SignatureAlgorithm.HS256, secretKey.getBytes())
|
||||||
|
.compact();
|
||||||
|
|
||||||
|
return new TokenDto(accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getMemberIdFromToken(String token) {
|
||||||
|
validateToken(token);
|
||||||
|
|
||||||
|
return Jwts.parser().setSigningKey(secretKey.getBytes()).parseClaimsJws(token)
|
||||||
|
.getBody()
|
||||||
|
.get("memberId", Long.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void validateToken(String token) {
|
||||||
|
try {
|
||||||
|
Jwts.parser().setSigningKey(secretKey.getBytes()).parseClaimsJws(token);
|
||||||
|
} catch (ExpiredJwtException e) {
|
||||||
|
throw new RoomEscapeException(ErrorType.EXPIRED_TOKEN, HttpStatus.UNAUTHORIZED);
|
||||||
|
} catch (UnsupportedJwtException e) {
|
||||||
|
throw new RoomEscapeException(ErrorType.UNSUPPORTED_TOKEN, HttpStatus.UNAUTHORIZED);
|
||||||
|
} catch (MalformedJwtException e) {
|
||||||
|
throw new RoomEscapeException(ErrorType.MALFORMED_TOKEN, HttpStatus.UNAUTHORIZED);
|
||||||
|
} catch (SignatureException e) {
|
||||||
|
throw new RoomEscapeException(ErrorType.INVALID_SIGNATURE_TOKEN, HttpStatus.UNAUTHORIZED);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
throw new RoomEscapeException(ErrorType.ILLEGAL_TOKEN, HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
package roomescape.system.auth.jwt.dto;
|
||||||
|
|
||||||
|
public record TokenDto(String accessToken) {
|
||||||
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
package roomescape.system.auth.resolver;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import org.springframework.core.MethodParameter;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.bind.support.WebDataBinderFactory;
|
||||||
|
import org.springframework.web.context.request.NativeWebRequest;
|
||||||
|
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
|
||||||
|
import org.springframework.web.method.support.ModelAndViewContainer;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.Cookie;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import roomescape.system.auth.annotation.MemberId;
|
||||||
|
import roomescape.system.auth.jwt.JwtHandler;
|
||||||
|
import roomescape.system.exception.ErrorType;
|
||||||
|
import roomescape.system.exception.RoomEscapeException;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class MemberIdResolver implements HandlerMethodArgumentResolver {
|
||||||
|
|
||||||
|
private static final String ACCESS_TOKEN_COOKIE_NAME = "accessToken";
|
||||||
|
|
||||||
|
private final JwtHandler jwtHandler;
|
||||||
|
|
||||||
|
public MemberIdResolver(JwtHandler jwtHandler) {
|
||||||
|
this.jwtHandler = jwtHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean supportsParameter(MethodParameter parameter) {
|
||||||
|
return parameter.hasParameterAnnotation(MemberId.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Object resolveArgument(
|
||||||
|
MethodParameter parameter,
|
||||||
|
ModelAndViewContainer mavContainer,
|
||||||
|
NativeWebRequest webRequest,
|
||||||
|
WebDataBinderFactory binderFactory
|
||||||
|
) throws Exception {
|
||||||
|
Cookie[] cookies = webRequest.getNativeRequest(HttpServletRequest.class).getCookies();
|
||||||
|
if (cookies == null) {
|
||||||
|
throw new RoomEscapeException(ErrorType.NOT_EXIST_COOKIE, HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
return Arrays.stream(cookies)
|
||||||
|
.filter(cookie -> cookie.getName().equals(ACCESS_TOKEN_COOKIE_NAME))
|
||||||
|
.findAny()
|
||||||
|
.map(cookie -> jwtHandler.getMemberIdFromToken(cookie.getValue()))
|
||||||
|
.orElseThrow(() -> new RoomEscapeException(ErrorType.INVALID_TOKEN, HttpStatus.UNAUTHORIZED));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
package roomescape.system.auth.service;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import roomescape.member.domain.Member;
|
||||||
|
import roomescape.member.service.MemberService;
|
||||||
|
import roomescape.system.auth.dto.LoginCheckResponse;
|
||||||
|
import roomescape.system.auth.dto.LoginRequest;
|
||||||
|
import roomescape.system.auth.jwt.JwtHandler;
|
||||||
|
import roomescape.system.auth.jwt.dto.TokenDto;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AuthService {
|
||||||
|
|
||||||
|
private final MemberService memberService;
|
||||||
|
private final JwtHandler jwtHandler;
|
||||||
|
|
||||||
|
public AuthService(MemberService memberService, JwtHandler jwtHandler) {
|
||||||
|
this.memberService = memberService;
|
||||||
|
this.jwtHandler = jwtHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public TokenDto login(LoginRequest request) {
|
||||||
|
Member member = memberService.findMemberByEmailAndPassword(request.email(), request.password());
|
||||||
|
|
||||||
|
return jwtHandler.createToken(member.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
public LoginCheckResponse checkLogin(Long memberId) {
|
||||||
|
Member member = memberService.findMemberById(memberId);
|
||||||
|
|
||||||
|
return new LoginCheckResponse(member.getName());
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/main/java/roomescape/system/config/JacksonConfig.java
Normal file
40
src/main/java/roomescape/system/config/JacksonConfig.java
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package roomescape.system.config;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
|
||||||
|
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class JacksonConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ObjectMapper objectMapper() {
|
||||||
|
ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
objectMapper.registerModule(javaTimeModule());
|
||||||
|
return objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public JavaTimeModule javaTimeModule() {
|
||||||
|
JavaTimeModule javaTimeModule = new JavaTimeModule();
|
||||||
|
|
||||||
|
javaTimeModule.addSerializer(LocalDate.class, new LocalDateSerializer(DateTimeFormatter.ISO_LOCAL_DATE));
|
||||||
|
javaTimeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(DateTimeFormatter.ISO_LOCAL_DATE));
|
||||||
|
|
||||||
|
javaTimeModule.addSerializer(LocalTime.class, new LocalTimeSerializer(DateTimeFormatter.ofPattern("HH:mm")));
|
||||||
|
javaTimeModule.addDeserializer(LocalTime.class,
|
||||||
|
new LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm")));
|
||||||
|
|
||||||
|
return javaTimeModule;
|
||||||
|
}
|
||||||
|
}
|
||||||
76
src/main/java/roomescape/system/config/SwaggerConfig.java
Normal file
76
src/main/java/roomescape/system/config/SwaggerConfig.java
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
package roomescape.system.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.models.OpenAPI;
|
||||||
|
import io.swagger.v3.oas.models.info.Info;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class SwaggerConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public OpenAPI openAPI() {
|
||||||
|
return new OpenAPI().info(apiInfo());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Info apiInfo() {
|
||||||
|
return new Info()
|
||||||
|
.title("방탈출 예약 API 문서")
|
||||||
|
.description("""
|
||||||
|
## API 테스트는 '1. 인증 / 인가 API' 의 '/login' 을 통해 로그인 후 사용해주세요.
|
||||||
|
|
||||||
|
### 테스트시 로그인 가능한 계정 정보
|
||||||
|
|
||||||
|
- 아래의 JSON 형태의 데이터를 그대로 복사한 뒤 'POST /login' 의 Request Body 에 넣어서 사용해주세요.
|
||||||
|
|
||||||
|
- **관리자**:
|
||||||
|
{
|
||||||
|
"email": "a@a.a",
|
||||||
|
"password": "a"
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
- **회원**:
|
||||||
|
|
||||||
|
- 1번 회원
|
||||||
|
{
|
||||||
|
"email": "1@1.1",
|
||||||
|
"password": "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
- 2번 회원
|
||||||
|
{
|
||||||
|
"email": "2@2.2",
|
||||||
|
"password": "2"
|
||||||
|
}
|
||||||
|
|
||||||
|
- 3번 회원
|
||||||
|
{
|
||||||
|
"email": "3@3.3",
|
||||||
|
"password": "3"
|
||||||
|
}
|
||||||
|
|
||||||
|
- 4번 회원
|
||||||
|
{
|
||||||
|
"email": "4@4.4",
|
||||||
|
"password": "4"
|
||||||
|
}
|
||||||
|
|
||||||
|
### 테스트시 사용할 수 있는 파라미터 정보
|
||||||
|
- **themeId**: 1(테스트1), 2(테스트2), 3(테스트3), 4(테스트4)
|
||||||
|
|
||||||
|
- **timeId**: 1(15:00), 2(16:00), 3(17:00), 4(18:00)
|
||||||
|
|
||||||
|
- **memberId**: 1(어드민), 2(회원1), 3(회원2), 4(회원3), 5(회원4)
|
||||||
|
|
||||||
|
- **reservationId**:
|
||||||
|
- 1 ~ 6: 예약 및 결제 완료 상태
|
||||||
|
|
||||||
|
- 7: 예약은 승인되었으나, 결제 대기 상태
|
||||||
|
|
||||||
|
- 8 ~ 10: 예약 대기 상태
|
||||||
|
""")
|
||||||
|
.version("1.0.0");
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/main/java/roomescape/system/config/WebMvcConfig.java
Normal file
38
src/main/java/roomescape/system/config/WebMvcConfig.java
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
package roomescape.system.config;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
import roomescape.system.auth.interceptor.AdminInterceptor;
|
||||||
|
import roomescape.system.auth.interceptor.LoginInterceptor;
|
||||||
|
import roomescape.system.auth.resolver.MemberIdResolver;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class WebMvcConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
private final MemberIdResolver memberIdResolver;
|
||||||
|
private final AdminInterceptor adminInterceptor;
|
||||||
|
private final LoginInterceptor loginInterceptor;
|
||||||
|
|
||||||
|
public WebMvcConfig(MemberIdResolver memberIdResolver, AdminInterceptor adminInterceptor,
|
||||||
|
LoginInterceptor loginInterceptor) {
|
||||||
|
this.memberIdResolver = memberIdResolver;
|
||||||
|
this.adminInterceptor = adminInterceptor;
|
||||||
|
this.loginInterceptor = loginInterceptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
|
||||||
|
resolvers.add(memberIdResolver);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
|
registry.addInterceptor(adminInterceptor);
|
||||||
|
registry.addInterceptor(loginInterceptor);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,15 @@
|
|||||||
|
package roomescape.system.dto.response;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import roomescape.system.exception.ErrorType;
|
||||||
|
|
||||||
|
@Schema(name = "예외 응답", description = "예외 발생 시 응답에 사용됩니다.")
|
||||||
|
public record ErrorResponse(
|
||||||
|
@Schema(description = "발생한 예외의 종류", example = "INVALID_REQUEST_DATA") ErrorType errorType,
|
||||||
|
@Schema(description = "예외 메시지", example = "요청 데이터 값이 올바르지 않습니다.") String message
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static ErrorResponse of(ErrorType errorType, String message) {
|
||||||
|
return new ErrorResponse(errorType, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,20 @@
|
|||||||
|
package roomescape.system.dto.response;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
@Schema(description = "API 응답 시에 사용합니다.")
|
||||||
|
public record RoomEscapeApiResponse<T>(
|
||||||
|
@Schema(description = "응답 메시지", defaultValue = SUCCESS_MESSAGE) String message,
|
||||||
|
@Schema(description = "응답 바디") T data
|
||||||
|
) {
|
||||||
|
|
||||||
|
private static final String SUCCESS_MESSAGE = "요청이 성공적으로 수행되었습니다.";
|
||||||
|
|
||||||
|
public static <T> RoomEscapeApiResponse<T> success(T data) {
|
||||||
|
return new RoomEscapeApiResponse<>(SUCCESS_MESSAGE, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> RoomEscapeApiResponse<T> success() {
|
||||||
|
return new RoomEscapeApiResponse<>(SUCCESS_MESSAGE, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/main/java/roomescape/system/exception/ErrorType.java
Normal file
60
src/main/java/roomescape/system/exception/ErrorType.java
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
package roomescape.system.exception;
|
||||||
|
|
||||||
|
public enum ErrorType {
|
||||||
|
|
||||||
|
// 400 Bad Request
|
||||||
|
REQUEST_DATA_BLANK("요청 데이터에 유효하지 않은 값(null OR 공백)이 포함되어있습니다."),
|
||||||
|
INVALID_REQUEST_DATA_TYPE("요청 데이터 형식이 올바르지 않습니다."),
|
||||||
|
INVALID_REQUEST_DATA("요청 데이터 값이 올바르지 않습니다."),
|
||||||
|
INVALID_DATE_RANGE("종료 날짜는 시작 날짜 이전일 수 없습니다."),
|
||||||
|
HAS_RESERVATION_OR_WAITING("같은 테마에 대한 예약(대기)는 한 번만 가능합니다."),
|
||||||
|
|
||||||
|
// 401 Unauthorized
|
||||||
|
EXPIRED_TOKEN("토큰이 만료되었습니다. 다시 로그인 해주세요."),
|
||||||
|
UNSUPPORTED_TOKEN("지원하지 않는 JWT 토큰입니다."),
|
||||||
|
MALFORMED_TOKEN("형식이 맞지 않는 JWT 토큰입니다."),
|
||||||
|
INVALID_SIGNATURE_TOKEN("잘못된 JWT 토큰 Signature 입니다."),
|
||||||
|
ILLEGAL_TOKEN("JWT 토큰의 Claim 이 비어있습니다."),
|
||||||
|
INVALID_TOKEN("JWT 토큰이 존재하지 않거나 유효하지 않습니다."),
|
||||||
|
NOT_EXIST_COOKIE("쿠키가 존재하지 않습니다. 로그인이 필요한 서비스입니다."),
|
||||||
|
|
||||||
|
// 403 Forbidden
|
||||||
|
LOGIN_REQUIRED("로그인이 필요한 서비스입니다."),
|
||||||
|
PERMISSION_DOES_NOT_EXIST("접근 권한이 존재하지 않습니다."),
|
||||||
|
|
||||||
|
// 404 Not Found
|
||||||
|
MEMBER_NOT_FOUND("회원(Member) 정보가 존재하지 않습니다."),
|
||||||
|
RESERVATION_NOT_FOUND("예약(Reservation) 정보가 존재하지 않습니다."),
|
||||||
|
RESERVATION_TIME_NOT_FOUND("예약 시간(ReservationTime) 정보가 존재하지 않습니다."),
|
||||||
|
THEME_NOT_FOUND("테마(Theme) 정보가 존재하지 않습니다."),
|
||||||
|
PAYMENT_NOT_POUND("결제(Payment) 정보가 존재하지 않습니다."),
|
||||||
|
|
||||||
|
// 405 Method Not Allowed
|
||||||
|
METHOD_NOT_ALLOWED("지원하지 않는 HTTP Method 입니다."),
|
||||||
|
|
||||||
|
// 409 Conflict
|
||||||
|
TIME_IS_USED_CONFLICT("삭제할 수 없는 시간대입니다. 예약이 존재하는지 확인해주세요."),
|
||||||
|
THEME_IS_USED_CONFLICT("삭제할 수 없는 테마입니다. 예약이 존재하는지 확인해주세요."),
|
||||||
|
TIME_DUPLICATED("이미 해당 시간이 존재합니다."),
|
||||||
|
THEME_DUPLICATED("같은 이름의 테마가 존재합니다."),
|
||||||
|
RESERVATION_DUPLICATED("해당 시간에 이미 예약이 존재합니다."),
|
||||||
|
RESERVATION_PERIOD_IN_PAST("이미 지난 시간대는 예약할 수 없습니다."),
|
||||||
|
CANCELED_BEFORE_PAYMENT("취소 시간이 결제 시간 이전일 수 없습니다."),
|
||||||
|
|
||||||
|
// 500 Internal Server Error,
|
||||||
|
INTERNAL_SERVER_ERROR("서버 내부에서 에러가 발생하였습니다."),
|
||||||
|
|
||||||
|
// Payment Error
|
||||||
|
PAYMENT_ERROR("결제(취소)에 실패했습니다. 결제(취소) 정보를 확인해주세요."),
|
||||||
|
PAYMENT_SERVER_ERROR("결제 서버에서 에러가 발생하였습니다. 잠시 후 다시 시도해주세요.");
|
||||||
|
|
||||||
|
private final String description;
|
||||||
|
|
||||||
|
ErrorType(String description) {
|
||||||
|
this.description = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
package roomescape.system.exception;
|
||||||
|
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.context.support.DefaultMessageSourceResolvable;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.converter.HttpMessageNotReadableException;
|
||||||
|
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.client.ResourceAccessException;
|
||||||
|
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import roomescape.system.dto.response.ErrorResponse;
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class ExceptionControllerAdvice {
|
||||||
|
|
||||||
|
private final Logger logger = LoggerFactory.getLogger(getClass());
|
||||||
|
|
||||||
|
@ExceptionHandler(value = {RoomEscapeException.class})
|
||||||
|
public ErrorResponse handleRoomEscapeException(RoomEscapeException e, HttpServletResponse response) {
|
||||||
|
logger.error("{}{}", e.getMessage(), e.getInvalidValue().orElse(""), e);
|
||||||
|
response.setStatus(e.getHttpStatus().value());
|
||||||
|
return ErrorResponse.of(e.getErrorType(), e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(ResourceAccessException.class)
|
||||||
|
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
public ErrorResponse handleResourceAccessException(ResourceAccessException e) {
|
||||||
|
logger.error(e.getMessage(), e);
|
||||||
|
return ErrorResponse.of(ErrorType.PAYMENT_SERVER_ERROR, ErrorType.PAYMENT_SERVER_ERROR.getDescription());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(value = HttpMessageNotReadableException.class)
|
||||||
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
|
public ErrorResponse handleHttpMessageNotReadableException(HttpMessageNotReadableException e) {
|
||||||
|
logger.error(e.getMessage(), e);
|
||||||
|
return ErrorResponse.of(ErrorType.INVALID_REQUEST_DATA_TYPE,
|
||||||
|
ErrorType.INVALID_REQUEST_DATA_TYPE.getDescription());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(value = MethodArgumentNotValidException.class)
|
||||||
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
|
public ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
|
||||||
|
String messages = e.getBindingResult().getAllErrors().stream()
|
||||||
|
.map(DefaultMessageSourceResolvable::getDefaultMessage)
|
||||||
|
.collect(Collectors.joining(", "));
|
||||||
|
|
||||||
|
logger.error(messages, e);
|
||||||
|
return ErrorResponse.of(ErrorType.INVALID_REQUEST_DATA, messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(value = HttpRequestMethodNotSupportedException.class)
|
||||||
|
@ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
|
||||||
|
public ErrorResponse handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) {
|
||||||
|
logger.error(e.getMessage(), e);
|
||||||
|
return ErrorResponse.of(ErrorType.METHOD_NOT_ALLOWED, ErrorType.METHOD_NOT_ALLOWED.getDescription());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(value = Exception.class)
|
||||||
|
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
public ErrorResponse handleException(Exception e) {
|
||||||
|
logger.error(e.getMessage(), e);
|
||||||
|
return ErrorResponse.of(ErrorType.INTERNAL_SERVER_ERROR, ErrorType.INTERNAL_SERVER_ERROR.getDescription());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
package roomescape.system.exception;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatusCode;
|
||||||
|
|
||||||
|
public class RoomEscapeException extends RuntimeException {
|
||||||
|
|
||||||
|
private final ErrorType errorType;
|
||||||
|
private final String message;
|
||||||
|
private final String invalidValue;
|
||||||
|
private final HttpStatusCode httpStatus;
|
||||||
|
|
||||||
|
public RoomEscapeException(ErrorType errorType, HttpStatusCode httpStatus) {
|
||||||
|
this(errorType, null, httpStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
public RoomEscapeException(ErrorType errorType, String invalidValue, HttpStatusCode httpStatus) {
|
||||||
|
this.errorType = errorType;
|
||||||
|
this.message = errorType.getDescription();
|
||||||
|
this.invalidValue = invalidValue;
|
||||||
|
this.httpStatus = httpStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ErrorType getErrorType() {
|
||||||
|
return errorType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpStatusCode getHttpStatus() {
|
||||||
|
return httpStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<String> getInvalidValue() {
|
||||||
|
return Optional.ofNullable(invalidValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getMessage() {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
101
src/main/java/roomescape/theme/controller/ThemeController.java
Normal file
101
src/main/java/roomescape/theme/controller/ThemeController.java
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
package roomescape.theme.controller;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import roomescape.system.auth.annotation.Admin;
|
||||||
|
import roomescape.system.auth.annotation.LoginRequired;
|
||||||
|
import roomescape.system.dto.response.ErrorResponse;
|
||||||
|
import roomescape.system.dto.response.RoomEscapeApiResponse;
|
||||||
|
import roomescape.theme.dto.ThemeRequest;
|
||||||
|
import roomescape.theme.dto.ThemeResponse;
|
||||||
|
import roomescape.theme.dto.ThemesResponse;
|
||||||
|
import roomescape.theme.service.ThemeService;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@Tag(name = "5. 테마 API", description = "테마를 조회 / 추가 / 삭제할 때 사용합니다.")
|
||||||
|
public class ThemeController {
|
||||||
|
|
||||||
|
private final ThemeService themeService;
|
||||||
|
|
||||||
|
public ThemeController(ThemeService themeService) {
|
||||||
|
this.themeService = themeService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@LoginRequired
|
||||||
|
@GetMapping("/themes")
|
||||||
|
@ResponseStatus(HttpStatus.OK)
|
||||||
|
@Operation(summary = "모든 테마 조회", description = "모든 테마를 조회합니다.", tags = "로그인이 필요한 API")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)
|
||||||
|
})
|
||||||
|
public RoomEscapeApiResponse<ThemesResponse> getAllThemes() {
|
||||||
|
return RoomEscapeApiResponse.success(themeService.findAllThemes());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/themes/most-reserved-last-week")
|
||||||
|
@ResponseStatus(HttpStatus.OK)
|
||||||
|
@Operation(summary = "가장 많이 예약된 테마 조회")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "200", description = "성공", useReturnTypeSchema = true)
|
||||||
|
})
|
||||||
|
public RoomEscapeApiResponse<ThemesResponse> getMostReservedThemes(
|
||||||
|
@RequestParam(defaultValue = "10") @Parameter(description = "최대로 조회할 테마 갯수") int count
|
||||||
|
) {
|
||||||
|
return RoomEscapeApiResponse.success(themeService.getMostReservedThemesByCount(count));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Admin
|
||||||
|
@PostMapping("/themes")
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
@Operation(summary = "테마 추가", tags = "관리자 로그인이 필요한 API")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "201", description = "성공", useReturnTypeSchema = true),
|
||||||
|
@ApiResponse(responseCode = "409", description = "같은 이름의 테마를 추가할 수 없습니다.",
|
||||||
|
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||||
|
})
|
||||||
|
public RoomEscapeApiResponse<ThemeResponse> saveTheme(
|
||||||
|
@Valid @RequestBody ThemeRequest request,
|
||||||
|
HttpServletResponse response
|
||||||
|
) {
|
||||||
|
ThemeResponse themeResponse = themeService.addTheme(request);
|
||||||
|
response.setHeader(HttpHeaders.LOCATION, "/themes/" + themeResponse.id());
|
||||||
|
|
||||||
|
return RoomEscapeApiResponse.success(themeResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Admin
|
||||||
|
@DeleteMapping("/themes/{id}")
|
||||||
|
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||||
|
@Operation(summary = "테마 삭제", tags = "관리자 로그인이 필요한 API")
|
||||||
|
@ApiResponses({
|
||||||
|
@ApiResponse(responseCode = "204", description = "성공", useReturnTypeSchema = true),
|
||||||
|
@ApiResponse(responseCode = "409", description = "예약된 테마는 삭제할 수 없습니다.",
|
||||||
|
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||||
|
})
|
||||||
|
public RoomEscapeApiResponse<Void> removeTheme(
|
||||||
|
@NotNull(message = "themeId는 null일 수 없습니다.") @PathVariable Long id
|
||||||
|
) {
|
||||||
|
themeService.removeThemeById(id);
|
||||||
|
|
||||||
|
return RoomEscapeApiResponse.success();
|
||||||
|
}
|
||||||
|
}
|
||||||
65
src/main/java/roomescape/theme/domain/Theme.java
Normal file
65
src/main/java/roomescape/theme/domain/Theme.java
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
package roomescape.theme.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
public class Theme {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
private String thumbnail;
|
||||||
|
|
||||||
|
protected Theme() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Theme(String name, String description, String thumbnail) {
|
||||||
|
this(null, name, description, thumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Theme(
|
||||||
|
Long id,
|
||||||
|
String name,
|
||||||
|
String description,
|
||||||
|
String thumbnail
|
||||||
|
) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
this.description = description;
|
||||||
|
this.thumbnail = thumbnail;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDescription() {
|
||||||
|
return description;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getThumbnail() {
|
||||||
|
return thumbnail;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return "Theme{" +
|
||||||
|
"id=" + id +
|
||||||
|
", name=" + name +
|
||||||
|
", description=" + description +
|
||||||
|
", thumbnail=" + thumbnail +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
package roomescape.theme.domain.repository;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
|
||||||
|
import roomescape.theme.domain.Theme;
|
||||||
|
|
||||||
|
public interface ThemeRepository extends JpaRepository<Theme, Long> {
|
||||||
|
|
||||||
|
@Query(value = """
|
||||||
|
SELECT t
|
||||||
|
FROM Theme t
|
||||||
|
RIGHT JOIN Reservation r ON t.id = r.theme.id
|
||||||
|
WHERE r.date BETWEEN :startDate AND :endDate
|
||||||
|
GROUP BY r.theme.id
|
||||||
|
ORDER BY COUNT(r.theme.id) DESC, t.id ASC
|
||||||
|
LIMIT :limit
|
||||||
|
""")
|
||||||
|
List<Theme> findTopNThemeBetweenStartDateAndEndDate(LocalDate startDate, LocalDate endDate, int limit);
|
||||||
|
|
||||||
|
boolean existsByName(String name);
|
||||||
|
|
||||||
|
@Query(value = """
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1
|
||||||
|
FROM Reservation r
|
||||||
|
WHERE r.theme.id = :id
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
boolean isReservedTheme(Long id);
|
||||||
|
}
|
||||||
21
src/main/java/roomescape/theme/dto/ThemeRequest.java
Normal file
21
src/main/java/roomescape/theme/dto/ThemeRequest.java
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package roomescape.theme.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
@Schema(name = "테마 저장 요청", description = "테마 정보를 저장할 때 사용합니다.")
|
||||||
|
public record ThemeRequest(
|
||||||
|
@NotBlank(message = "테마의 이름은 null 또는 공백일 수 없습니다.")
|
||||||
|
@Size(min = 1, max = 20, message = "테마의 이름은 1~20글자 사이여야 합니다.")
|
||||||
|
@Schema(description = "필수 값이며, 최대 20글자까지 입력 가능합니다.")
|
||||||
|
String name,
|
||||||
|
@NotBlank(message = "테마의 설명은 null 또는 공백일 수 없습니다.")
|
||||||
|
@Size(min = 1, max = 100, message = "테마의 설명은 1~100글자 사이여야 합니다.")
|
||||||
|
@Schema(description = "필수 값이며, 최대 100글자까지 입력 가능합니다.")
|
||||||
|
String description,
|
||||||
|
@NotBlank(message = "테마의 쌈네일은 null 또는 공백일 수 없습니다.")
|
||||||
|
@Schema(description = "필수 값이며, 썸네일 이미지 URL 을 입력해주세요.")
|
||||||
|
String thumbnail
|
||||||
|
) {
|
||||||
|
}
|
||||||
21
src/main/java/roomescape/theme/dto/ThemeResponse.java
Normal file
21
src/main/java/roomescape/theme/dto/ThemeResponse.java
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package roomescape.theme.dto;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import roomescape.theme.domain.Theme;
|
||||||
|
|
||||||
|
@Schema(name = "테마 정보", description = "테마 추가 및 조회 응답에 사용됩니다.")
|
||||||
|
public record ThemeResponse(
|
||||||
|
@Schema(description = "테마 번호. 테마를 식별할 때 사용합니다.")
|
||||||
|
Long id,
|
||||||
|
@Schema(description = "테마 이름. 중복을 허용하지 않습니다.")
|
||||||
|
String name,
|
||||||
|
@Schema(description = "테마 설명")
|
||||||
|
String description,
|
||||||
|
@Schema(description = "테마 썸네일 이미지 URL")
|
||||||
|
String thumbnail
|
||||||
|
) {
|
||||||
|
|
||||||
|
public static ThemeResponse from(Theme theme) {
|
||||||
|
return new ThemeResponse(theme.getId(), theme.getName(), theme.getDescription(), theme.getThumbnail());
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/main/java/roomescape/theme/dto/ThemesResponse.java
Normal file
11
src/main/java/roomescape/theme/dto/ThemesResponse.java
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
package roomescape.theme.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
@Schema(name = "테마 목록 조회 응답", description = "모든 테마 목록 조회 응답시 사용됩니다.")
|
||||||
|
public record ThemesResponse(
|
||||||
|
@Schema(description = "모든 테마 목록") List<ThemeResponse> themes
|
||||||
|
) {
|
||||||
|
}
|
||||||
84
src/main/java/roomescape/theme/service/ThemeService.java
Normal file
84
src/main/java/roomescape/theme/service/ThemeService.java
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
package roomescape.theme.service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import roomescape.reservation.domain.repository.ReservationRepository;
|
||||||
|
import roomescape.system.exception.ErrorType;
|
||||||
|
import roomescape.system.exception.RoomEscapeException;
|
||||||
|
import roomescape.theme.domain.Theme;
|
||||||
|
import roomescape.theme.domain.repository.ThemeRepository;
|
||||||
|
import roomescape.theme.dto.ThemeRequest;
|
||||||
|
import roomescape.theme.dto.ThemeResponse;
|
||||||
|
import roomescape.theme.dto.ThemesResponse;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
public class ThemeService {
|
||||||
|
|
||||||
|
private final ThemeRepository themeRepository;
|
||||||
|
private final ReservationRepository reservationRepository;
|
||||||
|
|
||||||
|
public ThemeService(ThemeRepository themeRepository, ReservationRepository reservationRepository) {
|
||||||
|
this.themeRepository = themeRepository;
|
||||||
|
this.reservationRepository = reservationRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Theme findThemeById(Long id) {
|
||||||
|
return themeRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new RoomEscapeException(ErrorType.THEME_NOT_FOUND,
|
||||||
|
String.format("[themeId: %d]", id), HttpStatus.BAD_REQUEST));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ThemesResponse findAllThemes() {
|
||||||
|
List<ThemeResponse> response = themeRepository.findAll()
|
||||||
|
.stream()
|
||||||
|
.map(ThemeResponse::from)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return new ThemesResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ThemesResponse getMostReservedThemesByCount(int count) {
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
LocalDate startDate = today.minusDays(7);
|
||||||
|
LocalDate endDate = today.minusDays(1);
|
||||||
|
|
||||||
|
List<ThemeResponse> response = themeRepository.findTopNThemeBetweenStartDateAndEndDate(startDate, endDate,
|
||||||
|
count)
|
||||||
|
.stream()
|
||||||
|
.map(ThemeResponse::from)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return new ThemesResponse(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ThemeResponse addTheme(ThemeRequest request) {
|
||||||
|
validateIsSameThemeNameExist(request.name());
|
||||||
|
Theme theme = themeRepository.save(new Theme(request.name(), request.description(), request.thumbnail()));
|
||||||
|
|
||||||
|
return ThemeResponse.from(theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateIsSameThemeNameExist(String name) {
|
||||||
|
if (themeRepository.existsByName(name)) {
|
||||||
|
throw new RoomEscapeException(ErrorType.THEME_DUPLICATED,
|
||||||
|
String.format("[name: %s]", name), HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void removeThemeById(Long id) {
|
||||||
|
if (themeRepository.isReservedTheme(id)) {
|
||||||
|
throw new RoomEscapeException(ErrorType.THEME_IS_USED_CONFLICT,
|
||||||
|
String.format("[themeId: %d]", id), HttpStatus.CONFLICT);
|
||||||
|
}
|
||||||
|
themeRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
package roomescape.view.controller;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
|
import roomescape.system.auth.annotation.Admin;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
public class AdminPageController {
|
||||||
|
|
||||||
|
@Admin
|
||||||
|
@GetMapping("/admin")
|
||||||
|
public String showAdminPage() {
|
||||||
|
return "admin/index";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Admin
|
||||||
|
@GetMapping("/admin/reservation")
|
||||||
|
public String showAdminReservationPage() {
|
||||||
|
return "admin/reservation-new";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Admin
|
||||||
|
@GetMapping("/admin/time")
|
||||||
|
public String showAdminTimePage() {
|
||||||
|
return "admin/time";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Admin
|
||||||
|
@GetMapping("/admin/theme")
|
||||||
|
public String showAdminThemePage() {
|
||||||
|
return "admin/theme";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Admin
|
||||||
|
@GetMapping("/admin/waiting")
|
||||||
|
public String showAdminWaitingPage() {
|
||||||
|
return "admin/waiting";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,13 @@
|
|||||||
|
package roomescape.view.controller;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
public class AuthPageController {
|
||||||
|
|
||||||
|
@GetMapping("/login")
|
||||||
|
public String showLoginPage() {
|
||||||
|
return "login";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
package roomescape.view.controller;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
|
import roomescape.system.auth.annotation.LoginRequired;
|
||||||
|
|
||||||
|
@Controller
|
||||||
|
public class ClientPageController {
|
||||||
|
|
||||||
|
@GetMapping("/")
|
||||||
|
public String showPopularThemePage() {
|
||||||
|
return "index";
|
||||||
|
}
|
||||||
|
|
||||||
|
@LoginRequired
|
||||||
|
@GetMapping("/reservation")
|
||||||
|
public String showReservationPage() {
|
||||||
|
return "reservation";
|
||||||
|
}
|
||||||
|
|
||||||
|
@LoginRequired
|
||||||
|
@GetMapping("/reservation-mine")
|
||||||
|
public String showReservationMinePage() {
|
||||||
|
return "reservation-mine";
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/main/resources/application.yaml
Normal file
37
src/main/resources/application.yaml
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
spring:
|
||||||
|
jpa:
|
||||||
|
show-sql: false
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
format_sql: true
|
||||||
|
ddl-auto: create-drop
|
||||||
|
defer-datasource-initialization: true
|
||||||
|
|
||||||
|
h2:
|
||||||
|
console:
|
||||||
|
enabled: true
|
||||||
|
path: /h2-console
|
||||||
|
datasource:
|
||||||
|
driver-class-name: org.h2.Driver
|
||||||
|
url: jdbc:h2:mem:database
|
||||||
|
username: sa
|
||||||
|
password:
|
||||||
|
|
||||||
|
security:
|
||||||
|
jwt:
|
||||||
|
token:
|
||||||
|
secret-key: daijawligagaf@LIJ$@U)9nagnalkkgalijaddljfi
|
||||||
|
access:
|
||||||
|
expire-length: 1800000 # 30 분
|
||||||
|
|
||||||
|
payment:
|
||||||
|
confirm-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6
|
||||||
|
read-timeout: 3
|
||||||
|
connect-timeout: 30
|
||||||
|
|
||||||
|
springdoc:
|
||||||
|
swagger-ui:
|
||||||
|
operationsSorter: method
|
||||||
|
tagsSorter: alpha
|
||||||
|
doc-expansion: none
|
||||||
|
override-with-generic-response: false
|
||||||
68
src/main/resources/data.sql
Normal file
68
src/main/resources/data.sql
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
insert into reservation_time(start_at)
|
||||||
|
values ('15:00');
|
||||||
|
insert into reservation_time(start_at)
|
||||||
|
values ('16:00');
|
||||||
|
insert into reservation_time(start_at)
|
||||||
|
values ('17:00');
|
||||||
|
insert into reservation_time(start_at)
|
||||||
|
values ('18:00');
|
||||||
|
|
||||||
|
insert into theme(name, description, thumbnail)
|
||||||
|
values ('테스트1', '테스트중', 'https://i.pinimg.com/236x/6e/bc/46/6ebc461a94a49f9ea3b8bbe2204145d4.jpg');
|
||||||
|
insert into theme(name, description, thumbnail)
|
||||||
|
values ('테스트2', '테스트중', 'https://i.pinimg.com/236x/6e/bc/46/6ebc461a94a49f9ea3b8bbe2204145d4.jpg');
|
||||||
|
insert into theme(name, description, thumbnail)
|
||||||
|
values ('테스트3', '테스트중', 'https://i.pinimg.com/236x/6e/bc/46/6ebc461a94a49f9ea3b8bbe2204145d4.jpg');
|
||||||
|
insert into theme(name, description, thumbnail)
|
||||||
|
values ('테스트4', '테스트중', 'https://i.pinimg.com/236x/6e/bc/46/6ebc461a94a49f9ea3b8bbe2204145d4.jpg');
|
||||||
|
|
||||||
|
insert into member(name, email, password, role)
|
||||||
|
values ('어드민', 'a@a.a', 'a', 'ADMIN');
|
||||||
|
insert into member(name, email, password, role)
|
||||||
|
values ('1호', '1@1.1', '1', 'MEMBER');
|
||||||
|
insert into member(name, email, password, role)
|
||||||
|
values ('2호', '2@2.2', '2', 'MEMBER');
|
||||||
|
insert into member(name, email, password, role)
|
||||||
|
values ('3호', '3@3.3', '3', 'MEMBER');
|
||||||
|
insert into member(name, email, password, role)
|
||||||
|
values ('4호', '4@4.4', '4', 'MEMBER');
|
||||||
|
|
||||||
|
-- 예약: 결제 완료
|
||||||
|
insert into reservation(member_id, date, time_id, theme_id, reservation_status)
|
||||||
|
values (1, DATEADD('DAY', -1, CURRENT_DATE()) - 1, 1, 1, 'CONFIRMED');
|
||||||
|
insert into reservation(member_id, date, time_id, theme_id, reservation_status)
|
||||||
|
values (2, DATEADD('DAY', -2, CURRENT_DATE()) - 2, 3, 2, 'CONFIRMED');
|
||||||
|
insert into reservation(member_id, date, time_id, theme_id, reservation_status)
|
||||||
|
values (3, DATEADD('DAY', -3, CURRENT_DATE()), 2, 2, 'CONFIRMED');
|
||||||
|
insert into reservation(member_id, date, time_id, theme_id, reservation_status)
|
||||||
|
values (4, DATEADD('DAY', -4, CURRENT_DATE()), 1, 2, 'CONFIRMED');
|
||||||
|
insert into reservation(member_id, date, time_id, theme_id, reservation_status)
|
||||||
|
values (5, DATEADD('DAY', -5, CURRENT_DATE()), 1, 3, 'CONFIRMED');
|
||||||
|
insert into reservation(member_id, date, time_id, theme_id, reservation_status)
|
||||||
|
values (2, DATEADD('DAY', 7, CURRENT_DATE()), 2, 4, 'CONFIRMED');
|
||||||
|
|
||||||
|
-- 예약: 결제 대기
|
||||||
|
insert into reservation(member_id, date, time_id, theme_id, reservation_status)
|
||||||
|
values (2, DATEADD('DAY', 8, CURRENT_DATE()), 2, 4, 'CONFIRMED_PAYMENT_REQUIRED');
|
||||||
|
|
||||||
|
-- 예약 대기
|
||||||
|
insert into reservation(member_id, date, time_id, theme_id, reservation_status)
|
||||||
|
values (3, DATEADD('DAY', 7, CURRENT_DATE()), 2, 4, 'WAITING');
|
||||||
|
insert into reservation(member_id, date, time_id, theme_id, reservation_status)
|
||||||
|
values (4, DATEADD('DAY', 7, CURRENT_DATE()), 2, 4, 'WAITING');
|
||||||
|
insert into reservation(member_id, date, time_id, theme_id, reservation_status)
|
||||||
|
values (5, DATEADD('DAY', 7, CURRENT_DATE()), 2, 4, 'WAITING');
|
||||||
|
|
||||||
|
-- 결제 정보
|
||||||
|
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
|
||||||
|
values ('orderId-1', 'paymentKey-1', 10000, 1, CURRENT_DATE);
|
||||||
|
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
|
||||||
|
values ('orderId-2', 'paymentKey-2', 20000, 2, CURRENT_DATE);
|
||||||
|
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
|
||||||
|
values ('orderId-3', 'paymentKey-3', 30000, 3, CURRENT_DATE);
|
||||||
|
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
|
||||||
|
values ('orderId-4', 'paymentKey-4', 40000, 4, CURRENT_DATE);
|
||||||
|
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
|
||||||
|
values ('orderId-5', 'paymentKey-5', 50000, 5, CURRENT_DATE);
|
||||||
|
insert into payment(order_id, payment_key, total_amount, reservation_id, approved_at)
|
||||||
|
values ('orderId-6', 'paymentKey-6', 60000, 6, CURRENT_DATE);
|
||||||
15
src/main/resources/static/css/reservation.css
Normal file
15
src/main/resources/static/css/reservation.css
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
.disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
#theme-slots .theme-slot.active, #time-slots .time-slot.active {
|
||||||
|
background-color: #0a3711 !important;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
#time-slots .time-slot.disabled {
|
||||||
|
background-color: #cccccc;
|
||||||
|
color: #666666;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
62
src/main/resources/static/css/style.css
Normal file
62
src/main/resources/static/css/style.css
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
.profile-image {
|
||||||
|
height: 30px;
|
||||||
|
width: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 5px; /* 이름과의 간격 조정 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item .dropdown-toggle::after {
|
||||||
|
display: none; /* 드롭다운 화살표 제거 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
margin-right: 10px; /* 네비게이션 간격 조정 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-container {
|
||||||
|
width: 70%;
|
||||||
|
margin: 50px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-container-title {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
margin: 10px 0;
|
||||||
|
border-radius: 5px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Solid 버튼 */
|
||||||
|
.btn-custom {
|
||||||
|
background-color: #0a3711; /* 버튼 기본 배경색 */
|
||||||
|
color: white; /* 버튼 텍스트 색상 */
|
||||||
|
border: 1px solid #0a3711; /* 테두리 색상 일치 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-custom:hover {
|
||||||
|
background-color: #083d0f; /* 호버 상태에서의 배경색 */
|
||||||
|
color: white; /* 호버 상태에서의 텍스트 색상 */
|
||||||
|
border: 1px solid #083d0f; /* 호버 상태에서의 테두리 색상 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Outline 버튼 */
|
||||||
|
.btn-outline-custom {
|
||||||
|
background-color: transparent; /* 버튼 기본 배경색 투명 */
|
||||||
|
color: #0a3711; /* 버튼 텍스트 색상 */
|
||||||
|
border: 1px solid #0a3711; /* 테두리 색상 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-custom:hover {
|
||||||
|
background-color: #0a3711; /* 호버 상태에서의 배경색 */
|
||||||
|
color: white; /* 호버 상태에서의 텍스트 색상 */
|
||||||
|
border: 1px solid #0a3711; /* 호버 상태에서의 테두리 색상 유지 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
132
src/main/resources/static/css/toss-style.css
Normal file
132
src/main/resources/static/css/toss-style.css
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
.w-100 {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-100 {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 24px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.max-w-540 {
|
||||||
|
max-width: 540px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-wrapper {
|
||||||
|
padding: 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 11px 22px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
background-color: #f2f4f6;
|
||||||
|
color: #4e5968;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 17px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn.primary {
|
||||||
|
background-color: #3282f6;
|
||||||
|
color: #f9fcff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-center {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-between {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.align-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-loading {
|
||||||
|
margin-top: 72px;
|
||||||
|
height: 400px;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.confirm-success {
|
||||||
|
display: none;
|
||||||
|
margin-top: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
margin-top: 32px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin-top: 32px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
color: #191f28;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #4e5968;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-section {
|
||||||
|
margin-top: 60px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-section .response-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333d48;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-section .response-text {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #4e5968;
|
||||||
|
font-size: 17px;
|
||||||
|
padding-left: 16px;
|
||||||
|
word-break: break-word;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.color-grey {
|
||||||
|
color: #b0b8c1;
|
||||||
|
}
|
||||||
BIN
src/main/resources/static/favicon.ico
Normal file
BIN
src/main/resources/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src/main/resources/static/image/admin-logo.png
Normal file
BIN
src/main/resources/static/image/admin-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
BIN
src/main/resources/static/image/default-profile.png
Normal file
BIN
src/main/resources/static/image/default-profile.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
45
src/main/resources/static/js/ranking.js
Normal file
45
src/main/resources/static/js/ranking.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
requestRead(`/themes/most-reserved-last-week?count=10`) // 인기 테마 목록 조회 API endpoint
|
||||||
|
.then(render)
|
||||||
|
.catch(error => console.error('Error fetching times:', error));
|
||||||
|
});
|
||||||
|
|
||||||
|
function formatDate(dateString) {
|
||||||
|
let date = new Date(dateString);
|
||||||
|
let year = date.getFullYear();
|
||||||
|
let month = (date.getMonth() + 1).toString().padStart(2, '0'); // '04'
|
||||||
|
let day = date.getDate().toString().padStart(2, '0'); // '28'
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}`; // '2024-04-28'
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(data) {
|
||||||
|
const container = document.getElementById('theme-ranking');
|
||||||
|
data.data.themes.forEach(theme => {
|
||||||
|
const name = theme.name;
|
||||||
|
const thumbnail = theme.thumbnail;
|
||||||
|
const description = theme.description;
|
||||||
|
|
||||||
|
const htmlContent = `
|
||||||
|
<img class="mr-3 img-thumbnail" src="${thumbnail}" alt="${name}">
|
||||||
|
<div class="media-body">
|
||||||
|
<h5 class="mt-0 mb-1">${name}</h5>
|
||||||
|
${description}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const div = document.createElement('li');
|
||||||
|
div.className = 'media my-4';
|
||||||
|
div.innerHTML = htmlContent;
|
||||||
|
|
||||||
|
container.appendChild(div);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestRead(endpoint) {
|
||||||
|
return fetch(endpoint)
|
||||||
|
.then(response => {
|
||||||
|
if (response.status === 200) return response.json();
|
||||||
|
throw new Error('Read failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
57
src/main/resources/static/js/reservation-mine.js
Normal file
57
src/main/resources/static/js/reservation-mine.js
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
fetch('/reservations-mine') // 내 예약 목록 조회 API 호출
|
||||||
|
.then(response => {
|
||||||
|
if (response.status === 200) return response.json();
|
||||||
|
throw new Error('Read failed');
|
||||||
|
})
|
||||||
|
.then(render)
|
||||||
|
.catch(error => console.error('Error fetching reservations:', error));
|
||||||
|
});
|
||||||
|
|
||||||
|
function render(data) {
|
||||||
|
const tableBody = document.getElementById('table-body');
|
||||||
|
tableBody.innerHTML = '';
|
||||||
|
data.data.myReservationResponses.forEach(item => {
|
||||||
|
const row = tableBody.insertRow();
|
||||||
|
|
||||||
|
const theme = item.themeName;
|
||||||
|
const date = item.date;
|
||||||
|
const time = item.time;
|
||||||
|
const status = item.status.includes('CONFIRMED') ? (item.status === 'CONFIRMED' ? '예약' : '예약 - 결제 필요') : item.rank + '번째 예약 대기';
|
||||||
|
|
||||||
|
row.insertCell(0).textContent = theme;
|
||||||
|
row.insertCell(1).textContent = date;
|
||||||
|
row.insertCell(2).textContent = time;
|
||||||
|
row.insertCell(3).textContent = status;
|
||||||
|
|
||||||
|
if (status.includes('대기')) { // 예약 대기 상태일 때 예약 대기 취소 버튼 추가하는 코드, 상태 값은 변경 가능
|
||||||
|
const cancelCell = row.insertCell(4);
|
||||||
|
const cancelButton = document.createElement('button');
|
||||||
|
cancelButton.textContent = '취소';
|
||||||
|
cancelButton.className = 'btn btn-danger';
|
||||||
|
cancelButton.onclick = function () {
|
||||||
|
requestDeleteWaiting(item.id).then(() => window.location.reload());
|
||||||
|
};
|
||||||
|
cancelCell.appendChild(cancelButton);
|
||||||
|
} else { // 예약 완료 상태일 때
|
||||||
|
/*
|
||||||
|
TODO: [미션4 - 2단계] 내 예약 목록 조회 시,
|
||||||
|
예약 완료 상태일 때 결제 정보를 함께 보여주기
|
||||||
|
결제 정보 필드명은 자신의 response 에 맞게 변경하기
|
||||||
|
*/
|
||||||
|
row.insertCell(4).textContent = '';
|
||||||
|
row.insertCell(5).textContent = item.paymentKey;
|
||||||
|
row.insertCell(6).textContent = item.amount;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDeleteWaiting(id) {
|
||||||
|
const endpoint = '/reservations/waiting/' + id;
|
||||||
|
return fetch(endpoint, {
|
||||||
|
method: 'DELETE'
|
||||||
|
}).then(response => {
|
||||||
|
if (response.status === 204) return;
|
||||||
|
throw new Error('Delete failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
194
src/main/resources/static/js/reservation-new.js
Normal file
194
src/main/resources/static/js/reservation-new.js
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
let isEditing = false;
|
||||||
|
const RESERVATION_API_ENDPOINT = '/reservations';
|
||||||
|
const TIME_API_ENDPOINT = '/times';
|
||||||
|
const THEME_API_ENDPOINT = '/themes';
|
||||||
|
const timesOptions = [];
|
||||||
|
const themesOptions = [];
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.getElementById('add-button').addEventListener('click', addInputRow);
|
||||||
|
|
||||||
|
requestRead(RESERVATION_API_ENDPOINT)
|
||||||
|
.then(render)
|
||||||
|
.catch(error => console.error('Error fetching reservations:', error));
|
||||||
|
|
||||||
|
fetchTimes();
|
||||||
|
fetchThemes();
|
||||||
|
});
|
||||||
|
|
||||||
|
function render(data) {
|
||||||
|
const tableBody = document.getElementById('table-body');
|
||||||
|
tableBody.innerHTML = '';
|
||||||
|
|
||||||
|
data.data.reservations.forEach(item => {
|
||||||
|
const row = tableBody.insertRow();
|
||||||
|
|
||||||
|
row.insertCell(0).textContent = item.id; // 예약 id
|
||||||
|
row.insertCell(1).textContent = item.name; // 예약자명
|
||||||
|
row.insertCell(2).textContent = item.theme.name; // 테마명
|
||||||
|
row.insertCell(3).textContent = item.date; // 예약 날짜
|
||||||
|
row.insertCell(4).textContent = item.time.startAt; // 시작 시간
|
||||||
|
|
||||||
|
const actionCell = row.insertCell(row.cells.length);
|
||||||
|
actionCell.appendChild(createActionButton('삭제', 'btn-danger', deleteRow));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchTimes() {
|
||||||
|
requestRead(TIME_API_ENDPOINT)
|
||||||
|
.then(data => {
|
||||||
|
timesOptions.push(...data.data.times);
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error fetching time:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchThemes() {
|
||||||
|
requestRead(THEME_API_ENDPOINT)
|
||||||
|
.then(data => {
|
||||||
|
themesOptions.push(...data.data.themes);
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error fetching theme:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSelect(options, defaultText, selectId, textProperty) {
|
||||||
|
const select = document.createElement('select');
|
||||||
|
select.className = 'form-control';
|
||||||
|
select.id = selectId;
|
||||||
|
|
||||||
|
// 기본 옵션 추가
|
||||||
|
const defaultOption = document.createElement('option');
|
||||||
|
defaultOption.textContent = defaultText;
|
||||||
|
select.appendChild(defaultOption);
|
||||||
|
|
||||||
|
// 넘겨받은 옵션을 바탕으로 드롭다운 메뉴 아이템 생성
|
||||||
|
options.forEach(optionData => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = optionData.id;
|
||||||
|
option.textContent = optionData[textProperty]; // 동적 속성 접근
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
return select;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createActionButton(label, className, eventListener) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.textContent = label;
|
||||||
|
button.classList.add('btn', className, 'mr-2');
|
||||||
|
button.addEventListener('click', eventListener);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addInputRow() {
|
||||||
|
if (isEditing) return; // 이미 편집 중인 경우 추가하지 않음
|
||||||
|
|
||||||
|
const tableBody = document.getElementById('table-body');
|
||||||
|
const row = tableBody.insertRow();
|
||||||
|
isEditing = true;
|
||||||
|
|
||||||
|
const nameInput = createInput('text');
|
||||||
|
const dateInput = createInput('date');
|
||||||
|
const timeDropdown = createSelect(timesOptions, "시간 선택", 'time-select', 'startAt');
|
||||||
|
const themeDropdown = createSelect(themesOptions, "테마 선택", 'theme-select', 'name');
|
||||||
|
|
||||||
|
const cellFieldsToCreate = ['', nameInput, themeDropdown, dateInput, timeDropdown];
|
||||||
|
|
||||||
|
cellFieldsToCreate.forEach((field, index) => {
|
||||||
|
const cell = row.insertCell(index);
|
||||||
|
if (typeof field === 'string') {
|
||||||
|
cell.textContent = field;
|
||||||
|
} else {
|
||||||
|
cell.appendChild(field);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionCell = row.insertCell(row.cells.length);
|
||||||
|
actionCell.appendChild(createActionButton('확인', 'btn-custom', saveRow));
|
||||||
|
actionCell.appendChild(createActionButton('취소', 'btn-secondary', () => {
|
||||||
|
row.remove();
|
||||||
|
isEditing = false;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInput(type) {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = type;
|
||||||
|
input.className = 'form-control';
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createActionButton(label, className, eventListener) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.textContent = label;
|
||||||
|
button.classList.add('btn', className, 'mr-2');
|
||||||
|
button.addEventListener('click', eventListener);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRow(event) {
|
||||||
|
// 이벤트 전파를 막는다
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const row = event.target.parentNode.parentNode;
|
||||||
|
const nameInput = row.querySelector('input[type="text"]');
|
||||||
|
const themeSelect = row.querySelector('#theme-select');
|
||||||
|
const dateInput = row.querySelector('input[type="date"]');
|
||||||
|
const timeSelect = row.querySelector('#time-select');
|
||||||
|
|
||||||
|
const reservation = {
|
||||||
|
name: nameInput.value,
|
||||||
|
themeId: themeSelect.value,
|
||||||
|
date: dateInput.value,
|
||||||
|
timeId: timeSelect.value
|
||||||
|
};
|
||||||
|
|
||||||
|
requestCreate(reservation)
|
||||||
|
.then(() => {
|
||||||
|
location.reload();
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error:', error));
|
||||||
|
|
||||||
|
isEditing = false; // isEditing 값을 false로 설정
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRow(event) {
|
||||||
|
const row = event.target.closest('tr');
|
||||||
|
const reservationId = row.cells[0].textContent;
|
||||||
|
|
||||||
|
requestDelete(reservationId)
|
||||||
|
.then(() => row.remove())
|
||||||
|
.catch(error => console.error('Error:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestCreate(reservation) {
|
||||||
|
const requestOptions = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(reservation)
|
||||||
|
};
|
||||||
|
|
||||||
|
return fetch(RESERVATION_API_ENDPOINT, requestOptions)
|
||||||
|
.then(response => {
|
||||||
|
if (response.status === 201) return response.json();
|
||||||
|
throw new Error('Create failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDelete(id) {
|
||||||
|
const requestOptions = {
|
||||||
|
method: 'DELETE',
|
||||||
|
};
|
||||||
|
|
||||||
|
return fetch(`${RESERVATION_API_ENDPOINT}/${id}`, requestOptions)
|
||||||
|
.then(response => {
|
||||||
|
if (response.status !== 204) throw new Error('Delete failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestRead(endpoint) {
|
||||||
|
return fetch(endpoint)
|
||||||
|
.then(response => {
|
||||||
|
if (response.status === 200) return response.json();
|
||||||
|
throw new Error('Read failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
250
src/main/resources/static/js/reservation-with-member.js
Normal file
250
src/main/resources/static/js/reservation-with-member.js
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
let isEditing = false;
|
||||||
|
const RESERVATION_API_ENDPOINT = '/reservations';
|
||||||
|
const TIME_API_ENDPOINT = '/times';
|
||||||
|
const THEME_API_ENDPOINT = '/themes';
|
||||||
|
const MEMBER_API_ENDPOINT = '/members';
|
||||||
|
const timesOptions = [];
|
||||||
|
const themesOptions = [];
|
||||||
|
const membersOptions = [];
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.getElementById('add-button').addEventListener('click', addInputRow);
|
||||||
|
document.getElementById('filter-form').addEventListener('submit', applyFilter);
|
||||||
|
|
||||||
|
requestRead(RESERVATION_API_ENDPOINT)
|
||||||
|
.then(render)
|
||||||
|
.catch(error => console.error('Error fetching reservations:', error));
|
||||||
|
|
||||||
|
fetchTimes();
|
||||||
|
fetchThemes();
|
||||||
|
fetchMembers();
|
||||||
|
});
|
||||||
|
|
||||||
|
function render(data) {
|
||||||
|
const tableBody = document.getElementById('table-body');
|
||||||
|
tableBody.innerHTML = '';
|
||||||
|
|
||||||
|
data.data.reservations.forEach(item => {
|
||||||
|
const row = tableBody.insertRow();
|
||||||
|
const isPaid = item.status === 'CONFIRMED' ? '결제 완료' : '결제 대기';
|
||||||
|
|
||||||
|
row.insertCell(0).textContent = item.id; // 예약 id
|
||||||
|
row.insertCell(1).textContent = item.member.name; // 사용자 name
|
||||||
|
row.insertCell(2).textContent = item.theme.name; // 테마 name
|
||||||
|
row.insertCell(3).textContent = item.date; // date
|
||||||
|
row.insertCell(4).textContent = item.time.startAt; // 예약 시간 startAt
|
||||||
|
row.insertCell(5).textContent = isPaid; // 결제
|
||||||
|
|
||||||
|
const actionCell = row.insertCell(row.cells.length);
|
||||||
|
actionCell.appendChild(createActionButton('삭제', 'btn-danger', deleteRow));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchTimes() {
|
||||||
|
requestRead(TIME_API_ENDPOINT)
|
||||||
|
.then(data => {
|
||||||
|
timesOptions.push(...data.data.times);
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error fetching time:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchThemes() {
|
||||||
|
requestRead(THEME_API_ENDPOINT)
|
||||||
|
.then(data => {
|
||||||
|
themesOptions.push(...data.data.themes);
|
||||||
|
populateSelect('theme', themesOptions, 'name');
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error fetching theme:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchMembers() {
|
||||||
|
requestRead(MEMBER_API_ENDPOINT)
|
||||||
|
.then(data => {
|
||||||
|
membersOptions.push(...data.data.members);
|
||||||
|
populateSelect('member', membersOptions, 'name');
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error fetching member:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateSelect(selectId, options, textProperty) {
|
||||||
|
const select = document.getElementById(selectId);
|
||||||
|
options.forEach(optionData => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = optionData.id;
|
||||||
|
option.textContent = optionData[textProperty];
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSelect(options, defaultText, selectId, textProperty) {
|
||||||
|
const select = document.createElement('select');
|
||||||
|
select.className = 'form-control';
|
||||||
|
select.id = selectId;
|
||||||
|
|
||||||
|
// 기본 옵션 추가
|
||||||
|
const defaultOption = document.createElement('option');
|
||||||
|
defaultOption.textContent = defaultText;
|
||||||
|
select.appendChild(defaultOption);
|
||||||
|
|
||||||
|
// 넘겨받은 옵션을 바탕으로 드롭다운 메뉴 아이템 생성
|
||||||
|
options.forEach(optionData => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = optionData.id;
|
||||||
|
option.textContent = optionData[textProperty]; // 동적 속성 접근
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
return select;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createActionButton(label, className, eventListener) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.textContent = label;
|
||||||
|
button.classList.add('btn', className, 'mr-2');
|
||||||
|
button.addEventListener('click', eventListener);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addInputRow() {
|
||||||
|
if (isEditing) return; // 이미 편집 중인 경우 추가하지 않음
|
||||||
|
|
||||||
|
const tableBody = document.getElementById('table-body');
|
||||||
|
const row = tableBody.insertRow();
|
||||||
|
isEditing = true;
|
||||||
|
|
||||||
|
const dateInput = createInput('date');
|
||||||
|
const timeDropdown = createSelect(timesOptions, "시간 선택", 'time-select', 'startAt');
|
||||||
|
const themeDropdown = createSelect(themesOptions, "테마 선택", 'theme-select', 'name');
|
||||||
|
const memberDropdown = createSelect(membersOptions, "멤버 선택", 'member-select', 'name');
|
||||||
|
|
||||||
|
const cellFieldsToCreate = ['', memberDropdown, themeDropdown, dateInput, timeDropdown];
|
||||||
|
|
||||||
|
cellFieldsToCreate.forEach((field, index) => {
|
||||||
|
const cell = row.insertCell(index);
|
||||||
|
if (typeof field === 'string') {
|
||||||
|
cell.textContent = field;
|
||||||
|
} else {
|
||||||
|
cell.appendChild(field);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionCell = row.insertCell(row.cells.length);
|
||||||
|
actionCell.appendChild(createActionButton('확인', 'btn-custom', saveRow));
|
||||||
|
actionCell.appendChild(createActionButton('취소', 'btn-secondary', () => {
|
||||||
|
row.remove();
|
||||||
|
isEditing = false;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInput(type) {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = type;
|
||||||
|
input.className = 'form-control';
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createActionButton(label, className, eventListener) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.textContent = label;
|
||||||
|
button.classList.add('btn', className, 'mr-2');
|
||||||
|
button.addEventListener('click', eventListener);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRow(event) {
|
||||||
|
// 이벤트 전파를 막는다
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const row = event.target.parentNode.parentNode;
|
||||||
|
const dateInput = row.querySelector('input[type="date"]');
|
||||||
|
const memberSelect = row.querySelector('#member-select');
|
||||||
|
const themeSelect = row.querySelector('#theme-select');
|
||||||
|
const timeSelect = row.querySelector('#time-select');
|
||||||
|
|
||||||
|
const reservation = {
|
||||||
|
date: dateInput.value,
|
||||||
|
themeId: themeSelect.value,
|
||||||
|
timeId: timeSelect.value,
|
||||||
|
memberId: memberSelect.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
requestCreate(reservation)
|
||||||
|
.then(() => {
|
||||||
|
location.reload();
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error:', error));
|
||||||
|
|
||||||
|
isEditing = false; // isEditing 값을 false로 설정
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRow(event) {
|
||||||
|
const row = event.target.closest('tr');
|
||||||
|
const reservationId = row.cells[0].textContent;
|
||||||
|
|
||||||
|
requestDelete(reservationId)
|
||||||
|
.then(() => row.remove())
|
||||||
|
.catch(error => console.error('Error:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilter(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const themeId = document.getElementById('theme').value;
|
||||||
|
const memberId = document.getElementById('member').value;
|
||||||
|
const dateFrom = document.getElementById('date-from').value;
|
||||||
|
const dateTo = document.getElementById('date-to').value;
|
||||||
|
|
||||||
|
const queryParams = {
|
||||||
|
themeId: themeId,
|
||||||
|
memberId: memberId,
|
||||||
|
dateFrom: dateFrom,
|
||||||
|
dateTo: dateTo
|
||||||
|
}
|
||||||
|
const searchParams = new URLSearchParams(queryParams);
|
||||||
|
const endpoint = '/reservations/search';
|
||||||
|
|
||||||
|
const url = `${endpoint}?${searchParams.toString()}`;
|
||||||
|
fetch(url, { // 예약 검색 API 호출
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
}).then(response => {
|
||||||
|
if (response.status === 200) return response.json();
|
||||||
|
throw new Error('Read failed');
|
||||||
|
}).then(render)
|
||||||
|
.catch(error => console.error("Error fetching available times:", error));
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestCreate(reservation) {
|
||||||
|
const requestOptions = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(reservation)
|
||||||
|
};
|
||||||
|
|
||||||
|
return fetch('/reservations/admin', requestOptions)
|
||||||
|
.then(response => {
|
||||||
|
if (response.status === 201) return response.json();
|
||||||
|
throw new Error('Create failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDelete(id) {
|
||||||
|
const requestOptions = {
|
||||||
|
method: 'DELETE',
|
||||||
|
};
|
||||||
|
|
||||||
|
return fetch(`${RESERVATION_API_ENDPOINT}/${id}`, requestOptions)
|
||||||
|
.then(response => {
|
||||||
|
if (response.status !== 204) throw new Error('Delete failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestRead(endpoint) {
|
||||||
|
return fetch(endpoint)
|
||||||
|
.then(response => {
|
||||||
|
if (response.status === 200) return response.json();
|
||||||
|
throw new Error('Read failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
179
src/main/resources/static/js/reservation.js
Normal file
179
src/main/resources/static/js/reservation.js
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
let isEditing = false;
|
||||||
|
const RESERVATION_API_ENDPOINT = '/reservations';
|
||||||
|
const TIME_API_ENDPOINT = '/times';
|
||||||
|
const timesOptions = [];
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.getElementById('add-button').addEventListener('click', addInputRow);
|
||||||
|
|
||||||
|
requestRead(RESERVATION_API_ENDPOINT)
|
||||||
|
.then(render)
|
||||||
|
.catch(error => console.error('Error fetching reservations:', error));
|
||||||
|
|
||||||
|
fetchTimes();
|
||||||
|
});
|
||||||
|
|
||||||
|
function render(data) {
|
||||||
|
const tableBody = document.getElementById('table-body');
|
||||||
|
tableBody.innerHTML = '';
|
||||||
|
|
||||||
|
data.data.reservations.forEach(item => {
|
||||||
|
const row = tableBody.insertRow();
|
||||||
|
|
||||||
|
row.insertCell(0).textContent = item.id;
|
||||||
|
row.insertCell(1).textContent = item.name;
|
||||||
|
row.insertCell(2).textContent = item.date;
|
||||||
|
row.insertCell(3).textContent = item.time.startAt;
|
||||||
|
|
||||||
|
const actionCell = row.insertCell(row.cells.length);
|
||||||
|
actionCell.appendChild(createActionButton('삭제', 'btn-danger', deleteRow));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchTimes() {
|
||||||
|
requestRead(TIME_API_ENDPOINT)
|
||||||
|
.then(data => {
|
||||||
|
timesOptions.push(...data.data.times);
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error fetching time:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSelect(options, defaultText, selectId, textProperty) {
|
||||||
|
const select = document.createElement('select');
|
||||||
|
select.className = 'form-control';
|
||||||
|
select.id = selectId;
|
||||||
|
|
||||||
|
// 기본 옵션 추가
|
||||||
|
const defaultOption = document.createElement('option');
|
||||||
|
defaultOption.textContent = defaultText;
|
||||||
|
select.appendChild(defaultOption);
|
||||||
|
|
||||||
|
// 넘겨받은 옵션을 바탕으로 드롭다운 메뉴 아이템 생성
|
||||||
|
options.forEach(optionData => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = optionData.id;
|
||||||
|
option.textContent = optionData[textProperty]; // 동적 속성 접근
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
return select;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createActionButton(label, className, eventListener) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.textContent = label;
|
||||||
|
button.classList.add('btn', className, 'mr-2');
|
||||||
|
button.addEventListener('click', eventListener);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addInputRow() {
|
||||||
|
if (isEditing) return; // 이미 편집 중인 경우 추가하지 않음
|
||||||
|
|
||||||
|
const tableBody = document.getElementById('table-body');
|
||||||
|
const row = tableBody.insertRow();
|
||||||
|
isEditing = true;
|
||||||
|
|
||||||
|
const nameInput = createInput('text');
|
||||||
|
const dateInput = createInput('date');
|
||||||
|
const timeDropdown = createSelect(timesOptions, "시간 선택", 'time-select', 'startAt');
|
||||||
|
|
||||||
|
const cellFieldsToCreate = ['', nameInput, dateInput, timeDropdown];
|
||||||
|
|
||||||
|
cellFieldsToCreate.forEach((field, index) => {
|
||||||
|
const cell = row.insertCell(index);
|
||||||
|
if (typeof field === 'string') {
|
||||||
|
cell.textContent = field;
|
||||||
|
} else {
|
||||||
|
cell.appendChild(field);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionCell = row.insertCell(row.cells.length);
|
||||||
|
actionCell.appendChild(createActionButton('확인', 'btn-custom', saveRow));
|
||||||
|
actionCell.appendChild(createActionButton('취소', 'btn-secondary', () => {
|
||||||
|
row.remove();
|
||||||
|
isEditing = false;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInput(type) {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = type;
|
||||||
|
input.className = 'form-control';
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createActionButton(label, className, eventListener) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.textContent = label;
|
||||||
|
button.classList.add('btn', className, 'mr-2');
|
||||||
|
button.addEventListener('click', eventListener);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRow(event) {
|
||||||
|
// 이벤트 전파를 막는다
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const row = event.target.parentNode.parentNode;
|
||||||
|
const nameInput = row.querySelector('input[type="text"]');
|
||||||
|
const dateInput = row.querySelector('input[type="date"]');
|
||||||
|
const timeSelect = row.querySelector('select');
|
||||||
|
|
||||||
|
const reservation = {
|
||||||
|
name: nameInput.value,
|
||||||
|
date: dateInput.value,
|
||||||
|
timeId: timeSelect.value
|
||||||
|
};
|
||||||
|
|
||||||
|
requestCreate(reservation)
|
||||||
|
.then(() => {
|
||||||
|
location.reload();
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error:', error));
|
||||||
|
|
||||||
|
isEditing = false; // isEditing 값을 false로 설정
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRow(event) {
|
||||||
|
const row = event.target.closest('tr');
|
||||||
|
const reservationId = row.cells[0].textContent;
|
||||||
|
|
||||||
|
requestDelete(reservationId)
|
||||||
|
.then(() => row.remove())
|
||||||
|
.catch(error => console.error('Error:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestCreate(reservation) {
|
||||||
|
const requestOptions = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(reservation)
|
||||||
|
};
|
||||||
|
|
||||||
|
return fetch(RESERVATION_API_ENDPOINT, requestOptions)
|
||||||
|
.then(response => {
|
||||||
|
if (response.status === 201) return response.json();
|
||||||
|
throw new Error('Create failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDelete(id) {
|
||||||
|
const requestOptions = {
|
||||||
|
method: 'DELETE',
|
||||||
|
};
|
||||||
|
|
||||||
|
return fetch(`${RESERVATION_API_ENDPOINT}/${id}`, requestOptions)
|
||||||
|
.then(response => {
|
||||||
|
if (response.status !== 204) throw new Error('Delete failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestRead(endpoint) {
|
||||||
|
return fetch(endpoint)
|
||||||
|
.then(response => {
|
||||||
|
if (response.status === 200) return response.json();
|
||||||
|
throw new Error('Read failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
0
src/main/resources/static/js/scripts.js
Normal file
0
src/main/resources/static/js/scripts.js
Normal file
136
src/main/resources/static/js/theme.js
Normal file
136
src/main/resources/static/js/theme.js
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
let isEditing = false;
|
||||||
|
const API_ENDPOINT = '/themes';
|
||||||
|
const cellFields = ['id', 'name', 'description', 'thumbnail'];
|
||||||
|
const createCellFields = ['', createInput(), createInput(), createInput()];
|
||||||
|
|
||||||
|
function createBody(inputs) {
|
||||||
|
return {
|
||||||
|
name: inputs[0].value,
|
||||||
|
description: inputs[1].value,
|
||||||
|
thumbnail: inputs[2].value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.getElementById('add-button').addEventListener('click', addRow);
|
||||||
|
requestRead()
|
||||||
|
.then(render)
|
||||||
|
.catch(error => console.error('Error fetching times:', error));
|
||||||
|
});
|
||||||
|
|
||||||
|
function render(data) {
|
||||||
|
const tableBody = document.getElementById('table-body');
|
||||||
|
tableBody.innerHTML = '';
|
||||||
|
|
||||||
|
data.data.themes.forEach(item => {
|
||||||
|
const row = tableBody.insertRow();
|
||||||
|
|
||||||
|
cellFields.forEach((field, index) => {
|
||||||
|
row.insertCell(index).textContent = item[field];
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionCell = row.insertCell(row.cells.length);
|
||||||
|
actionCell.appendChild(createActionButton('삭제', 'btn-danger', deleteRow));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRow() {
|
||||||
|
if (isEditing) return; // 이미 편집 중인 경우 추가하지 않음
|
||||||
|
|
||||||
|
const tableBody = document.getElementById('table-body');
|
||||||
|
const row = tableBody.insertRow();
|
||||||
|
isEditing = true;
|
||||||
|
|
||||||
|
createAddField(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAddField(row) {
|
||||||
|
createCellFields.forEach((field, index) => {
|
||||||
|
const cell = row.insertCell(index);
|
||||||
|
if (typeof field === 'string') {
|
||||||
|
cell.textContent = field;
|
||||||
|
} else {
|
||||||
|
cell.appendChild(field);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionCell = row.insertCell(row.cells.length);
|
||||||
|
actionCell.appendChild(createActionButton('확인', 'btn-custom', saveRow));
|
||||||
|
actionCell.appendChild(createActionButton('취소', 'btn-secondary', () => {
|
||||||
|
row.remove();
|
||||||
|
isEditing = false;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInput() {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.className = 'form-control';
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createActionButton(label, className, eventListener) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.textContent = label;
|
||||||
|
button.classList.add('btn', className, 'mr-2');
|
||||||
|
button.addEventListener('click', eventListener);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRow(event) {
|
||||||
|
const row = event.target.parentNode.parentNode;
|
||||||
|
const inputs = row.querySelectorAll('input');
|
||||||
|
const body = createBody(inputs);
|
||||||
|
|
||||||
|
requestCreate(body)
|
||||||
|
.then(() => {
|
||||||
|
location.reload();
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error:', error));
|
||||||
|
|
||||||
|
isEditing = false; // isEditing 값을 false로 설정
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRow(event) {
|
||||||
|
const row = event.target.closest('tr');
|
||||||
|
const id = row.cells[0].textContent;
|
||||||
|
|
||||||
|
requestDelete(id)
|
||||||
|
.then(() => row.remove())
|
||||||
|
.catch(error => console.error('Error:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// request
|
||||||
|
|
||||||
|
function requestCreate(data) {
|
||||||
|
const requestOptions = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
};
|
||||||
|
|
||||||
|
return fetch(API_ENDPOINT, requestOptions)
|
||||||
|
.then(response => {
|
||||||
|
if (response.status === 201) return response.json();
|
||||||
|
throw new Error('Create failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestRead() {
|
||||||
|
return fetch(API_ENDPOINT)
|
||||||
|
.then(response => {
|
||||||
|
if (response.status === 200) return response.json();
|
||||||
|
throw new Error('Read failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDelete(id) {
|
||||||
|
const requestOptions = {
|
||||||
|
method: 'DELETE',
|
||||||
|
};
|
||||||
|
|
||||||
|
return fetch(`${API_ENDPOINT}/${id}`, requestOptions)
|
||||||
|
.then(response => {
|
||||||
|
if (response.status !== 204) throw new Error('Delete failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
135
src/main/resources/static/js/time.js
Normal file
135
src/main/resources/static/js/time.js
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
let isEditing = false;
|
||||||
|
const API_ENDPOINT = '/times';
|
||||||
|
const cellFields = ['id', 'startAt'];
|
||||||
|
const createCellFields = ['', createInput()];
|
||||||
|
|
||||||
|
function createBody(inputs) {
|
||||||
|
return {
|
||||||
|
startAt: inputs[0].value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
document.getElementById('add-button').addEventListener('click', addRow);
|
||||||
|
requestRead()
|
||||||
|
.then(render)
|
||||||
|
.catch(error => console.error('Error fetching times:', error));
|
||||||
|
});
|
||||||
|
|
||||||
|
function render(data) {
|
||||||
|
const tableBody = document.getElementById('table-body');
|
||||||
|
tableBody.innerHTML = '';
|
||||||
|
|
||||||
|
data.data.times.forEach(item => {
|
||||||
|
const row = tableBody.insertRow();
|
||||||
|
|
||||||
|
cellFields.forEach((field, index) => {
|
||||||
|
row.insertCell(index).textContent = item[field];
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionCell = row.insertCell(row.cells.length);
|
||||||
|
actionCell.appendChild(createActionButton('삭제', 'btn-danger', deleteRow));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRow() {
|
||||||
|
if (isEditing) return; // 이미 편집 중인 경우 추가하지 않음
|
||||||
|
|
||||||
|
const tableBody = document.getElementById('table-body');
|
||||||
|
const row = tableBody.insertRow();
|
||||||
|
isEditing = true;
|
||||||
|
|
||||||
|
createAddField(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAddField(row) {
|
||||||
|
createCellFields.forEach((field, index) => {
|
||||||
|
const cell = row.insertCell(index);
|
||||||
|
if (typeof field === 'string') {
|
||||||
|
cell.textContent = field;
|
||||||
|
} else {
|
||||||
|
cell.appendChild(field);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const actionCell = row.insertCell(row.cells.length);
|
||||||
|
actionCell.appendChild(createActionButton('확인', 'btn-custom', saveRow));
|
||||||
|
actionCell.appendChild(createActionButton('취소', 'btn-secondary', () => {
|
||||||
|
row.remove();
|
||||||
|
isEditing = false;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInput() {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'time'
|
||||||
|
input.className = 'form-control';
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createActionButton(label, className, eventListener) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.textContent = label;
|
||||||
|
button.classList.add('btn', className, 'mr-2');
|
||||||
|
button.addEventListener('click', eventListener);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveRow(event) {
|
||||||
|
const row = event.target.parentNode.parentNode;
|
||||||
|
const inputs = row.querySelectorAll('input');
|
||||||
|
const body = createBody(inputs);
|
||||||
|
|
||||||
|
requestCreate(body)
|
||||||
|
.then(() => {
|
||||||
|
location.reload();
|
||||||
|
})
|
||||||
|
.catch(error => console.error('Error:', error));
|
||||||
|
|
||||||
|
isEditing = false; // isEditing 값을 false로 설정
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteRow(event) {
|
||||||
|
const row = event.target.closest('tr');
|
||||||
|
const id = row.cells[0].textContent;
|
||||||
|
|
||||||
|
requestDelete(id)
|
||||||
|
.then(() => row.remove())
|
||||||
|
.catch(error => console.error('Error:', error));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// request
|
||||||
|
|
||||||
|
function requestCreate(data) {
|
||||||
|
const requestOptions = {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
};
|
||||||
|
|
||||||
|
return fetch(API_ENDPOINT, requestOptions)
|
||||||
|
.then(response => {
|
||||||
|
if (response.status === 201) return response.json();
|
||||||
|
throw new Error('Create failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestRead() {
|
||||||
|
return fetch(API_ENDPOINT)
|
||||||
|
.then(response => {
|
||||||
|
if (response.status === 200) return response.json();
|
||||||
|
throw new Error('Read failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDelete(id) {
|
||||||
|
const requestOptions = {
|
||||||
|
method: 'DELETE',
|
||||||
|
};
|
||||||
|
|
||||||
|
return fetch(`${API_ENDPOINT}/${id}`, requestOptions)
|
||||||
|
.then(response => {
|
||||||
|
if (response.status !== 204) throw new Error('Delete failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
273
src/main/resources/static/js/user-reservation.js
Normal file
273
src/main/resources/static/js/user-reservation.js
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
const THEME_API_ENDPOINT = '/themes';
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
requestRead(THEME_API_ENDPOINT)
|
||||||
|
.then(renderTheme)
|
||||||
|
.catch(error => console.error('Error fetching times:', error));
|
||||||
|
|
||||||
|
flatpickr("#datepicker", {
|
||||||
|
inline: true,
|
||||||
|
onChange: function (selectedDates, dateStr, instance) {
|
||||||
|
if (dateStr === '') return;
|
||||||
|
checkDate();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ------ 결제위젯 초기화 ------
|
||||||
|
// @docs https://docs.tosspayments.com/reference/widget-sdk#sdk-설치-및-초기화
|
||||||
|
// @docs https://docs.tosspayments.com/reference/widget-sdk#renderpaymentmethods선택자-결제-금액-옵션
|
||||||
|
const paymentAmount = 1000;
|
||||||
|
const widgetClientKey = "test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm";
|
||||||
|
const paymentWidget = PaymentWidget(widgetClientKey, PaymentWidget.ANONYMOUS);
|
||||||
|
paymentWidget.renderPaymentMethods(
|
||||||
|
"#payment-method",
|
||||||
|
{value: paymentAmount},
|
||||||
|
{variantKey: "DEFAULT"}
|
||||||
|
);
|
||||||
|
|
||||||
|
document.getElementById('theme-slots').addEventListener('click', event => {
|
||||||
|
if (event.target.classList.contains('theme-slot')) {
|
||||||
|
document.querySelectorAll('.theme-slot').forEach(slot => slot.classList.remove('active'));
|
||||||
|
event.target.classList.add('active');
|
||||||
|
checkDateAndTheme();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('time-slots').addEventListener('click', event => {
|
||||||
|
if (event.target.classList.contains('time-slot') && !event.target.classList.contains('disabled')) {
|
||||||
|
document.querySelectorAll('.time-slot').forEach(slot => slot.classList.remove('active'));
|
||||||
|
event.target.classList.add('active');
|
||||||
|
checkDateAndThemeAndTime();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('reserve-button').addEventListener('click', onReservationButtonClickWithPaymentWidget);
|
||||||
|
document.getElementById('wait-button').addEventListener('click', onWaitButtonClick);
|
||||||
|
|
||||||
|
function onReservationButtonClickWithPaymentWidget(event) {
|
||||||
|
onReservationButtonClick(event, paymentWidget);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderTheme(themes) {
|
||||||
|
const themeSlots = document.getElementById('theme-slots');
|
||||||
|
themeSlots.innerHTML = '';
|
||||||
|
themes.data.themes.forEach(theme => {
|
||||||
|
const name = theme.name;
|
||||||
|
const themeId = theme.id;
|
||||||
|
themeSlots.appendChild(createSlot('theme', name, themeId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSlot(type, text, id, booked) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = type + '-slot cursor-pointer bg-light border rounded p-3 mb-2';
|
||||||
|
div.textContent = text;
|
||||||
|
div.setAttribute('data-' + type + '-id', id);
|
||||||
|
if (type === 'time') {
|
||||||
|
div.setAttribute('data-time-booked', booked);
|
||||||
|
}
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkDate() {
|
||||||
|
const selectedDate = document.getElementById("datepicker").value;
|
||||||
|
if (selectedDate) {
|
||||||
|
const themeSection = document.getElementById("theme-section");
|
||||||
|
if (themeSection.classList.contains("disabled")) {
|
||||||
|
themeSection.classList.remove("disabled");
|
||||||
|
}
|
||||||
|
const timeSlots = document.getElementById('time-slots');
|
||||||
|
timeSlots.innerHTML = '';
|
||||||
|
|
||||||
|
requestRead(THEME_API_ENDPOINT)
|
||||||
|
.then(renderTheme)
|
||||||
|
.catch(error => console.error('Error fetching times:', error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkDateAndTheme() {
|
||||||
|
const selectedDate = document.getElementById("datepicker").value;
|
||||||
|
const selectedThemeElement = document.querySelector('.theme-slot.active');
|
||||||
|
if (selectedDate && selectedThemeElement) {
|
||||||
|
const selectedThemeId = selectedThemeElement.getAttribute('data-theme-id');
|
||||||
|
fetchAvailableTimes(selectedDate, selectedThemeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchAvailableTimes(date, themeId) {
|
||||||
|
|
||||||
|
fetch(`/times/filter?date=${date}&themeId=${themeId}`, { // 예약 가능 시간 조회 API endpoint
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
}).then(response => {
|
||||||
|
if (response.status === 200) return response.json();
|
||||||
|
throw new Error('Read failed');
|
||||||
|
}).then(renderAvailableTimes)
|
||||||
|
.catch(error => console.error("Error fetching available times:", error));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAvailableTimes(times) {
|
||||||
|
const timeSection = document.getElementById("time-section");
|
||||||
|
if (timeSection.classList.contains("disabled")) {
|
||||||
|
timeSection.classList.remove("disabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeSlots = document.getElementById('time-slots');
|
||||||
|
timeSlots.innerHTML = '';
|
||||||
|
if (times.length === 0) {
|
||||||
|
timeSlots.innerHTML = '<div class="no-times">선택할 수 있는 시간이 없습니다.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
times.data.reservationTimes.forEach(time => {
|
||||||
|
const startAt = time.startAt;
|
||||||
|
const timeId = time.timeId;
|
||||||
|
const alreadyBooked = time.alreadyBooked;
|
||||||
|
|
||||||
|
const div = createSlot('time', startAt, timeId, alreadyBooked); // createSlot('time', 시작 시간, time id, 예약 여부)
|
||||||
|
timeSlots.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkDateAndThemeAndTime() {
|
||||||
|
const selectedDate = document.getElementById("datepicker").value;
|
||||||
|
const selectedThemeElement = document.querySelector('.theme-slot.active');
|
||||||
|
const selectedTimeElement = document.querySelector('.time-slot.active');
|
||||||
|
const reserveButton = document.getElementById("reserve-button");
|
||||||
|
const waitButton = document.getElementById("wait-button");
|
||||||
|
|
||||||
|
if (selectedDate && selectedThemeElement && selectedTimeElement) {
|
||||||
|
if (selectedTimeElement.getAttribute('data-time-booked') === 'true') {
|
||||||
|
// 선택된 시간이 이미 예약된 경우
|
||||||
|
reserveButton.classList.add("disabled");
|
||||||
|
waitButton.classList.remove("disabled"); // 예약 대기 버튼 활성화
|
||||||
|
} else {
|
||||||
|
// 선택된 시간이 예약 가능한 경우
|
||||||
|
reserveButton.classList.remove("disabled");
|
||||||
|
waitButton.classList.add("disabled"); // 예약 대기 버튼 활성화
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 날짜, 테마, 시간 중 하나라도 선택되지 않은 경우
|
||||||
|
reserveButton.classList.add("disabled");
|
||||||
|
waitButton.classList.add("disabled");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onReservationButtonClick(event, paymentWidget) {
|
||||||
|
const selectedDate = document.getElementById("datepicker").value;
|
||||||
|
const selectedThemeId = document.querySelector('.theme-slot.active')?.getAttribute('data-theme-id');
|
||||||
|
const selectedTimeId = document.querySelector('.time-slot.active')?.getAttribute('data-time-id');
|
||||||
|
|
||||||
|
if (selectedDate && selectedThemeId && selectedTimeId) {
|
||||||
|
const reservationData = {
|
||||||
|
date: selectedDate,
|
||||||
|
themeId: selectedThemeId,
|
||||||
|
timeId: selectedTimeId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const generateRandomString = () =>
|
||||||
|
window.btoa(Math.random()).slice(0, 20);
|
||||||
|
|
||||||
|
// TOSS 결제 위젯 Javascript SDK 연동 방식 중 'Promise로 처리하기'를 적용함
|
||||||
|
// https://docs.tosspayments.com/reference/widget-sdk#promise%EB%A1%9C-%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0
|
||||||
|
const orderIdPrefix = "WTEST";
|
||||||
|
paymentWidget.requestPayment({
|
||||||
|
orderId: orderIdPrefix + generateRandomString(),
|
||||||
|
orderName: "테스트 방탈출 예약 결제 1건",
|
||||||
|
amount: 1000,
|
||||||
|
}).then(function (data) {
|
||||||
|
console.debug(data);
|
||||||
|
fetchReservationPayment(data, reservationData);
|
||||||
|
}).catch(function (error) {
|
||||||
|
// TOSS 에러 처리: 에러 목록을 확인하세요
|
||||||
|
// https://docs.tosspayments.com/reference/error-codes#failurl 로-전달되는-에러
|
||||||
|
alert(error.code + " :" + error.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
alert("Please select a date, theme, and time before making a reservation.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchReservationPayment(paymentData, reservationData) {
|
||||||
|
const reservationPaymentRequest = {
|
||||||
|
date: reservationData.date,
|
||||||
|
themeId: reservationData.themeId,
|
||||||
|
timeId: reservationData.timeId,
|
||||||
|
paymentKey: paymentData.paymentKey,
|
||||||
|
orderId: paymentData.orderId,
|
||||||
|
amount: paymentData.amount,
|
||||||
|
paymentType: paymentData.paymentType,
|
||||||
|
}
|
||||||
|
|
||||||
|
const reservationURL = "/reservations";
|
||||||
|
fetch(reservationURL, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(reservationPaymentRequest),
|
||||||
|
}).then(response => {
|
||||||
|
if (!response.ok) {
|
||||||
|
return response.json().then(errorBody => {
|
||||||
|
console.error("예약 결제 실패 : " + JSON.stringify(errorBody));
|
||||||
|
window.alert("예약 결제 실패 메시지: " + errorBody.message);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
response.json().then(successBody => {
|
||||||
|
alert("예약이 완료되었습니다.");
|
||||||
|
console.log("예약 결제 성공 : " + JSON.stringify(successBody));
|
||||||
|
window.location.href = "/";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch(error => {
|
||||||
|
console.error(error.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWaitButtonClick() {
|
||||||
|
const selectedDate = document.getElementById("datepicker").value;
|
||||||
|
const selectedThemeId = document.querySelector('.theme-slot.active')?.getAttribute('data-theme-id');
|
||||||
|
const selectedTimeId = document.querySelector('.time-slot.active')?.getAttribute('data-time-id');
|
||||||
|
|
||||||
|
if (selectedDate && selectedThemeId && selectedTimeId) {
|
||||||
|
const reservationData = {
|
||||||
|
date: selectedDate,
|
||||||
|
timeId: selectedTimeId,
|
||||||
|
themeId: selectedThemeId,
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch('/reservations/waiting', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(reservationData)
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (!response.ok) throw new Error('Reservation waiting failed');
|
||||||
|
return response.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
alert('Reservation waiting successful!');
|
||||||
|
window.location.href = "/";
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
alert("An error occurred while making the reservation waiting.");
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
alert("Please select a date, theme, and time before making a reservation waiting.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function requestRead(endpoint) {
|
||||||
|
return fetch(endpoint)
|
||||||
|
.then(response => {
|
||||||
|
if (response.status === 200) return response.json();
|
||||||
|
throw new Error('Read failed');
|
||||||
|
});
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user