load origin project files

This commit is contained in:
이상진 2025-07-12 13:02:09 +09:00
parent 6c7f7dd97a
commit 04bf97518e
142 changed files with 10102 additions and 45 deletions

77
.gitignore vendored
View File

@ -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
View 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
View 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

Binary file not shown.

View 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
View 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
View 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

View 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();
}
}

View File

@ -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());
}
}

View 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 +
'}';
}
}

View File

@ -0,0 +1,6 @@
package roomescape.member.domain;
public enum Role {
MEMBER,
ADMIN
}

View File

@ -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);
}

View 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());
}
}

View 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
) {
}

View 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));
}
}

View 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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View 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;
}
}

View 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;
}
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -0,0 +1,4 @@
package roomescape.payment.dto.request;
public record PaymentCancelRequest(String paymentKey, Long amount, String cancelReason) {
}

View File

@ -0,0 +1,4 @@
package roomescape.payment.dto.request;
public record PaymentRequest(String paymentKey, String orderId, Long amount, String paymentType) {
}

View File

@ -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
) {
}

View File

@ -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())
);
}
}

View File

@ -0,0 +1,11 @@
package roomescape.payment.dto.response;
import java.time.OffsetDateTime;
public record PaymentResponse(
String paymentKey,
String orderId,
OffsetDateTime approvedAt,
Long totalAmount
) {
}

View File

@ -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());
}
}

View File

@ -0,0 +1,4 @@
package roomescape.payment.dto.response;
public record TossPaymentErrorResponse(String code, String message) {
}

View 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);
}
}

View File

@ -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);
}
}

View File

@ -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));
}
}

View 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);
}
}

View File

@ -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;
}

View File

@ -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 +
'}';
}
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -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);
}

View File

@ -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
) {
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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
) {
}

View File

@ -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);
}
}

View File

@ -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
) {
}

View File

@ -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()
);
}
}

View File

@ -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
) {
}

View File

@ -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
) {
}

View File

@ -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());
}
}

View File

@ -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
) {
}

View File

@ -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
) {
}

View File

@ -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);
}
}

View File

@ -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));
}
}

View File

@ -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);
}
}

View 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 {
}

View 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 LoginRequired {
}

View 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.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface MemberId {
}

View File

@ -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);
}
}

View File

@ -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
) {
}

View 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
) {
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View 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);
}
}
}

View File

@ -0,0 +1,4 @@
package roomescape.system.auth.jwt.dto;
public record TokenDto(String accessToken) {
}

View File

@ -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));
}
}

View File

@ -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());
}
}

View 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;
}
}

View 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");
}
}

View 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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View 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;
}
}

View File

@ -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());
}
}

View File

@ -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;
}
}

View 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();
}
}

View 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 +
'}';
}
}

View File

@ -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);
}

View 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
) {
}

View 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());
}
}

View 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
) {
}

View 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);
}
}

View File

@ -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";
}
}

View File

@ -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";
}
}

View File

@ -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";
}
}

View 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

View 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);

View 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;
}

View 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;
}

View 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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View 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');
});
}

View 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');
});
}

View 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');
});
}

View 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');
});
}

View 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');
});
}

View File

View 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');
});
}

View 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');
});
}

View 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