From d70189d0af9bf932d6fcc6e40f02bc595688c283 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Sat, 2 Aug 2025 15:03:19 +0900 Subject: [PATCH 01/27] update : --- .gitattributes | 3 + .gitignore | 40 +++ build.gradle | 38 +++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 251 ++++++++++++++++++ gradlew.bat | 94 +++++++ settings.gradle | 1 + .../book_bot/BookBotApplication.java | 13 + src/main/resources/application.properties | 1 + .../book_bot/BookBotApplicationTests.java | 13 + 11 files changed, 461 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 build.gradle create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 src/main/java/com/fastcampus/book_bot/BookBotApplication.java create mode 100644 src/main/resources/application.properties create mode 100644 src/test/java/com/fastcampus/book_bot/BookBotApplicationTests.java diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dabf52d --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +# MacOs +.DS_Store + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..cacf325 --- /dev/null +++ b/build.gradle @@ -0,0 +1,38 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.5.4' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.fastcampus' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-web' + compileOnly 'org.projectlombok:lombok' + runtimeOnly 'com.mysql:mysql-connector-j' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..1035081 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'Book_Bot' diff --git a/src/main/java/com/fastcampus/book_bot/BookBotApplication.java b/src/main/java/com/fastcampus/book_bot/BookBotApplication.java new file mode 100644 index 0000000..a63443e --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/BookBotApplication.java @@ -0,0 +1,13 @@ +package com.fastcampus.book_bot; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class BookBotApplication { + + public static void main(String[] args) { + SpringApplication.run(BookBotApplication.class, args); + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..5ae89c7 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.application.name=Book_Bot diff --git a/src/test/java/com/fastcampus/book_bot/BookBotApplicationTests.java b/src/test/java/com/fastcampus/book_bot/BookBotApplicationTests.java new file mode 100644 index 0000000..1bb5206 --- /dev/null +++ b/src/test/java/com/fastcampus/book_bot/BookBotApplicationTests.java @@ -0,0 +1,13 @@ +package com.fastcampus.book_bot; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class BookBotApplicationTests { + + @Test + void contextLoads() { + } + +} From 4db942921ccb5c180de6085d6babf5fd1d188dbf Mon Sep 17 00:00:00 2001 From: JiHoon Date: Mon, 4 Aug 2025 21:41:50 +0900 Subject: [PATCH 02/27] =?UTF-8?q?update=20:=20properties=20->=20yaml=20mys?= =?UTF-8?q?ql=EC=9D=84=20=EC=9C=84=ED=95=9C=20docker=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mysql-docker/Dockerfile | 12 ++++++++++++ mysql-docker/mysql-compose.yml | 19 +++++++++++++++++++ src/main/resources/application.properties | 1 - 3 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 mysql-docker/Dockerfile create mode 100644 mysql-docker/mysql-compose.yml delete mode 100644 src/main/resources/application.properties diff --git a/mysql-docker/Dockerfile b/mysql-docker/Dockerfile new file mode 100644 index 0000000..d415a18 --- /dev/null +++ b/mysql-docker/Dockerfile @@ -0,0 +1,12 @@ +FROM mysql:8.0 + +ENV MYSQL_ROOT_PASSWORD=1234 +ENV MYSQL_DATABASE=Book_Store +ENV MYSQL_USER=jihoon +ENV MYSQL_PASSWORD=1234 + +EXPOSE 3306 + +VOLUME ["/var/lib/mysql"] + +CMD ["mysqld", "--default-authentication-plugin=mysql_native_password"] diff --git a/mysql-docker/mysql-compose.yml b/mysql-docker/mysql-compose.yml new file mode 100644 index 0000000..b0fbf49 --- /dev/null +++ b/mysql-docker/mysql-compose.yml @@ -0,0 +1,19 @@ +services: + mysql: + build: . + container_name: bookstore-mysql + restart: always + ports: + - "3306:3306" + volumes: + - mysql-data:/var/lib/mysql + networks: + - bookstore-network + +volumes: + mysql-data: + driver: local + +networks: + bookstore-network: + driver: bridge \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 5ae89c7..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=Book_Bot From a0e1771b0bc3e4ff9ec46484cc92d285f60852ad Mon Sep 17 00:00:00 2001 From: JiHoon Date: Mon, 4 Aug 2025 21:44:14 +0900 Subject: [PATCH 03/27] =?UTF-8?q?update=20:=20WebClient=20Config=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B0=8F=20API=20=EC=9A=94=EC=B2=AD&?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20Service=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 + .../book_bot/BookBotApplication.java | 2 + .../book_bot/config/WebClientConfig.java | 45 +++++++++++++++ .../com/fastcampus/book_bot/domain/Book.java | 55 +++++++++++++++++++ .../dto/api/NaverBookResponseDTO.java | 28 ++++++++++ .../book_bot/service/api/NaverBookAPI.java | 55 +++++++++++++++++++ 6 files changed, 187 insertions(+) create mode 100644 src/main/java/com/fastcampus/book_bot/config/WebClientConfig.java create mode 100644 src/main/java/com/fastcampus/book_bot/domain/Book.java create mode 100644 src/main/java/com/fastcampus/book_bot/dto/api/NaverBookResponseDTO.java create mode 100644 src/main/java/com/fastcampus/book_bot/service/api/NaverBookAPI.java diff --git a/build.gradle b/build.gradle index cacf325..3b4c552 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,8 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-webflux' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/com/fastcampus/book_bot/BookBotApplication.java b/src/main/java/com/fastcampus/book_bot/BookBotApplication.java index a63443e..1c00d83 100644 --- a/src/main/java/com/fastcampus/book_bot/BookBotApplication.java +++ b/src/main/java/com/fastcampus/book_bot/BookBotApplication.java @@ -2,7 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +@EnableJpaAuditing @SpringBootApplication public class BookBotApplication { diff --git a/src/main/java/com/fastcampus/book_bot/config/WebClientConfig.java b/src/main/java/com/fastcampus/book_bot/config/WebClientConfig.java new file mode 100644 index 0000000..6135986 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/config/WebClientConfig.java @@ -0,0 +1,45 @@ +package com.fastcampus.book_bot.config; + +import io.netty.channel.ChannelOption; +import io.netty.handler.timeout.ReadTimeoutHandler; +import io.netty.handler.timeout.WriteTimeoutHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.netty.http.client.HttpClient; + +import java.time.Duration; + +@Configuration +public class WebClientConfig { + + /* 네이버 API를 호출하기 위한 WebClient 설정 + - 기본 URL: https://openapi.naver.com/v1/search + - 요청 헤더: Content-Type을 application/json으로 설정 + - 최대 메모리 크기: 4MB로 설정 + - 커넥션 타임아웃: 10초 + - 응답 타임아웃: 30초 + - 읽기/쓰기 타임아웃: 30초 + * */ + @Bean + public WebClient naverAPIWebClient() { + return WebClient.builder() + .baseUrl("https://openapi.naver.com/v1/search") + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .codecs(configurer -> { + configurer.defaultCodecs().maxInMemorySize(4 * 1024 * 1024); + }) + .clientConnector(new ReactorClientHttpConnector( + HttpClient.create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10000) + .responseTimeout(Duration.ofSeconds(30)) + .doOnConnected(conn -> + conn.addHandlerLast(new ReadTimeoutHandler(30)) + .addHandlerLast(new WriteTimeoutHandler(30))) + )) + .build(); + } +} diff --git a/src/main/java/com/fastcampus/book_bot/domain/Book.java b/src/main/java/com/fastcampus/book_bot/domain/Book.java new file mode 100644 index 0000000..b07949a --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/domain/Book.java @@ -0,0 +1,55 @@ +package com.fastcampus.book_bot.domain; + +import jakarta.persistence.*; +import jakarta.persistence.Id; +import org.springframework.data.annotation.*; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "books") +@EntityListeners(AuditingEntityListener.class) +public class Book { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "BOOK_ID", nullable = false, updatable = false) + private Long bookId; + + @Column(name = "BOOK_TITLE", length = 100, nullable = false) + private String bookTitle; + + @Column(name = "BOOK_AUTHOR", length = 200) + private String bookAuthor; + + @Column(name = "BOOK_LINK", length = 200) + private String bookLink; + + @Column(name = "BOOK_IMAGE", length = 200) + private String bookImage; + + @Column(name = "BOOK_PUBLISHER", length = 100) + private String bookPublisher; + + @Column(name = "BOOK_ISBN") + private Integer bookIsbn; + + @Column(name = "BOOK_DESCRIPTION", length = 500) + private String bookDescription; + + @Column(name = "BOOK_PUBDATE") + private LocalDateTime bookPubdate; + + @Column(name = "BOOK_DISCOUNT") + private Integer bookDiscount; + + @CreatedDate + @Column(name = "CREATED_AT", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "UPDATED_AT") + private LocalDateTime updatedAt; + +} diff --git a/src/main/java/com/fastcampus/book_bot/dto/api/NaverBookResponseDTO.java b/src/main/java/com/fastcampus/book_bot/dto/api/NaverBookResponseDTO.java new file mode 100644 index 0000000..c78cdc5 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/dto/api/NaverBookResponseDTO.java @@ -0,0 +1,28 @@ +package com.fastcampus.book_bot.dto.api; + +import lombok.*; + +import java.time.LocalDateTime; + +@Data +public class NaverBookResponseDTO { + + private int total; + private int start; + private int display; + private NaverBookItemDTO[] items; + + @Data + public static class NaverBookItemDTO { + + private String title; + private String link; + private String image; + private String author; + private int discount; + private String publisher; + private int isbn; + private String description; + private LocalDateTime pubdate; + } +} diff --git a/src/main/java/com/fastcampus/book_bot/service/api/NaverBookAPI.java b/src/main/java/com/fastcampus/book_bot/service/api/NaverBookAPI.java new file mode 100644 index 0000000..60e9974 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/service/api/NaverBookAPI.java @@ -0,0 +1,55 @@ +package com.fastcampus.book_bot.service.api; + +import com.fastcampus.book_bot.dto.api.NaverBookResponseDTO; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatusCode; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.time.Duration; + +@Service +@Slf4j +public class NaverBookAPI { + + @Value("${naver.api.client-id}") + private String clientId; + + @Value("${naver.api.client-secret}") + private String clientSecret; + + private final WebClient webClient; + + public NaverBookAPI(WebClient webClient) { + this.webClient = webClient; + } + + public Mono searchBooks(String query, int start, int display) { + return webClient.get() + .uri(uriBuilder -> uriBuilder.path("/book.json") + .queryParam("query", query) + .queryParam("start", start) + .queryParam("display", display) + .build()) + .header("X-Naver-Client-Id", clientId) + .header("X-Naver-Client-Secret", clientSecret) + .retrieve() + .onStatus(HttpStatusCode::is4xxClientError, + response -> { + log.warn("클라이언트 오류: {}", response.statusCode()); + return Mono.error(new RuntimeException("Client Error: " + response.statusCode())); + }) + .onStatus(HttpStatusCode::is5xxServerError, + response -> { + log.warn("서버 오류: {}", response.statusCode()); + return Mono.error(new RuntimeException("Server Error: " + response.statusCode())); + }) + .bodyToMono(NaverBookResponseDTO.class) + .timeout(Duration.ofSeconds(30)) + .doOnNext(response -> log.debug("API 응답 성공: 총 {} 건", response.getTotal())) + .doOnError(error -> log.error("네이버 도서 API 비동기 호출 실패: {}", error.getMessage())) + .onErrorReturn(new NaverBookResponseDTO()); + } +} From c4ba9d8c6a009bece48711c21c11e671a7064b95 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Tue, 5 Aug 2025 21:49:03 +0900 Subject: [PATCH 04/27] =?UTF-8?q?update=20:=20=EB=84=A4=EC=9D=B4=EB=B2=84?= =?UTF-8?q?=20=EB=8F=84=EC=84=9C=20API=20->=20RDB=20=EC=B4=88=EC=95=88?= =?UTF-8?q?=EB=B3=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + .../book_bot/config/WebClientConfig.java | 1 + .../book_bot/controller/BookController.java | 38 ++++++++ .../com/fastcampus/book_bot/domain/Book.java | 8 ++ .../book_bot/repository/BookRepository.java | 13 +++ .../service/api/ApiToMySQLService.java | 92 +++++++++++++++++++ ...rBookAPI.java => NaverBookAPIService.java} | 9 +- 7 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/fastcampus/book_bot/controller/BookController.java create mode 100644 src/main/java/com/fastcampus/book_bot/repository/BookRepository.java create mode 100644 src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java rename src/main/java/com/fastcampus/book_bot/service/api/{NaverBookAPI.java => NaverBookAPIService.java} (91%) diff --git a/build.gradle b/build.gradle index 3b4c552..ce265ca 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-batch' implementation 'org.springframework.boot:spring-boot-starter-webflux' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/src/main/java/com/fastcampus/book_bot/config/WebClientConfig.java b/src/main/java/com/fastcampus/book_bot/config/WebClientConfig.java index 6135986..150c41f 100644 --- a/src/main/java/com/fastcampus/book_bot/config/WebClientConfig.java +++ b/src/main/java/com/fastcampus/book_bot/config/WebClientConfig.java @@ -24,6 +24,7 @@ public class WebClientConfig { - 응답 타임아웃: 30초 - 읽기/쓰기 타임아웃: 30초 * */ + @Bean public WebClient naverAPIWebClient() { return WebClient.builder() diff --git a/src/main/java/com/fastcampus/book_bot/controller/BookController.java b/src/main/java/com/fastcampus/book_bot/controller/BookController.java new file mode 100644 index 0000000..20d40dd --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/controller/BookController.java @@ -0,0 +1,38 @@ +package com.fastcampus.book_bot.controller; + +import com.fastcampus.book_bot.domain.Book; +import com.fastcampus.book_bot.service.api.ApiToMySQLService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +import java.util.List; + +@RestController +@RequestMapping("/api/books") +@Slf4j +public class BookController { + + private final ApiToMySQLService apiToMySQLService; + + public BookController(ApiToMySQLService apiToMySQLService) { + this.apiToMySQLService = apiToMySQLService; + } + + @PostMapping("/search") + public Mono>> searchAndSaveBooks( + @RequestParam String query, + @RequestParam(defaultValue = "1") int start, + @RequestParam(defaultValue = "10") int display) { + + log.info("도서 검색 요청: query={}, start={}, display={}", query, start, display); + + return apiToMySQLService.searchAndSaveBooks(query, start, display) + .map(ResponseEntity::ok) + .onErrorReturn(ResponseEntity.internalServerError().build()); + } +} diff --git a/src/main/java/com/fastcampus/book_bot/domain/Book.java b/src/main/java/com/fastcampus/book_bot/domain/Book.java index b07949a..52c5ae7 100644 --- a/src/main/java/com/fastcampus/book_bot/domain/Book.java +++ b/src/main/java/com/fastcampus/book_bot/domain/Book.java @@ -2,6 +2,10 @@ import jakarta.persistence.*; import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; import org.springframework.data.annotation.*; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @@ -10,6 +14,10 @@ @Entity @Table(name = "books") @EntityListeners(AuditingEntityListener.class) +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor public class Book { @Id diff --git a/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java b/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java new file mode 100644 index 0000000..f148bb9 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java @@ -0,0 +1,13 @@ +package com.fastcampus.book_bot.repository; + +import com.fastcampus.book_bot.domain.Book; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface BookRepository extends JpaRepository { + + Optional findByBookIsbn(Integer bookIsbn); +} \ No newline at end of file diff --git a/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java b/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java new file mode 100644 index 0000000..ceb216d --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java @@ -0,0 +1,92 @@ +package com.fastcampus.book_bot.service.api; + +import com.fastcampus.book_bot.domain.Book; +import com.fastcampus.book_bot.dto.api.NaverBookResponseDTO; +import com.fastcampus.book_bot.repository.BookRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import reactor.core.publisher.Mono; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +@Service +@Slf4j +public class ApiToMySQLService { + + /* 네이버 API를 통해 도서 정보를 검색하고 MySQL에 저장하는 서비스 + * - 도서 정보를 검색하고, 중복된 도서는 저장하지 않음 + * - 응답이 없거나 오류가 발생하면 로그에 기록 + * - 저장된 도서의 개수를 로그에 출력 + * - 트랜잭션을 사용하여 데이터 일관성 유지 + * */ + + + private final BookRepository bookRepository; + private final NaverBookAPIService naverBookAPIService; + + public ApiToMySQLService(BookRepository bookRepository, NaverBookAPIService naverBookAPIService) { + this.bookRepository = bookRepository; + this.naverBookAPIService = naverBookAPIService; + } + + @Transactional + public Mono> searchAndSaveBooks(String query, int start, int display) { + return naverBookAPIService.searchBooks(query, start, display) + .map(this::saveBooks) + .doOnNext(books -> log.info("총 {} 건의 도서가 저장되었습니다.", books.size())) + .doOnError(error -> log.error("도서 검색 및 저장 중 오류 발생: {}", error.getMessage())); + } + + @Transactional + public List saveBooks(NaverBookResponseDTO response) { + List savedBooks = new ArrayList<>(); + + if (response.getItems() == null || response.getItems().length == 0) { + log.warn("API 응답에 책 정보가 없습니다."); + return savedBooks; + } + + for (NaverBookResponseDTO.NaverBookItemDTO item : response.getItems()) { + try { + Book book = convertToBook(item); + + if (!isDuplicateBook(book)) { + Book savedBook = bookRepository.save(book); + savedBooks.add(savedBook); + } + } catch (Exception e) { + { + log.error("도서 저장 중 오류 발생: {}, 도서: {}", e.getMessage(), item.getTitle()); + } + } + } + + return savedBooks; + } + + private Book convertToBook(NaverBookResponseDTO.NaverBookItemDTO item) { + return Book.builder() + .bookTitle(item.getTitle()) + .bookAuthor(item.getAuthor()) + .bookLink(item.getLink()) + .bookImage(item.getImage()) + .bookPublisher(item.getPublisher()) + .bookIsbn(item.getIsbn()) + .bookDescription(item.getDescription()) + .bookPubdate(item.getPubdate()) + .bookDiscount(item.getDiscount()) + .build(); + } + + private boolean isDuplicateBook(Book book) { + + if (book.getBookIsbn() != null && book.getBookIsbn() != 0) { + Optional existingBook = bookRepository.findByBookIsbn(book.getBookIsbn()); + return existingBook.isPresent(); + } + return false; + } +} diff --git a/src/main/java/com/fastcampus/book_bot/service/api/NaverBookAPI.java b/src/main/java/com/fastcampus/book_bot/service/api/NaverBookAPIService.java similarity index 91% rename from src/main/java/com/fastcampus/book_bot/service/api/NaverBookAPI.java rename to src/main/java/com/fastcampus/book_bot/service/api/NaverBookAPIService.java index 60e9974..98c4b52 100644 --- a/src/main/java/com/fastcampus/book_bot/service/api/NaverBookAPI.java +++ b/src/main/java/com/fastcampus/book_bot/service/api/NaverBookAPIService.java @@ -12,7 +12,12 @@ @Service @Slf4j -public class NaverBookAPI { +public class NaverBookAPIService { + + /* 네이버 API 요청&응답 클래스 작성 + - 비동기식 + - 응답 타임아웃 30초 + * */ @Value("${naver.api.client-id}") private String clientId; @@ -22,7 +27,7 @@ public class NaverBookAPI { private final WebClient webClient; - public NaverBookAPI(WebClient webClient) { + public NaverBookAPIService(WebClient webClient) { this.webClient = webClient; } From 311d3de11dc5f09cd09d4555d7847e85ced94a4c Mon Sep 17 00:00:00 2001 From: JiHoon Date: Thu, 7 Aug 2025 14:24:10 +0900 Subject: [PATCH 05/27] =?UTF-8?q?update=20:=20=EB=84=A4=EC=9D=B4=EB=B2=84?= =?UTF-8?q?=20=EB=8F=84=EC=84=9C=20API=20->=20MySQL=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../book_bot/controller/BookController.java | 16 ++++------- .../com/fastcampus/book_bot/domain/Book.java | 11 ++++---- .../fastcampus/book_bot/dto/api/BookDTO.java | 23 +++++++++++++++ .../dto/api/NaverBookResponseDTO.java | 16 +---------- .../book_bot/repository/BookRepository.java | 2 +- .../service/api/ApiToMySQLService.java | 28 +++++++++---------- .../service/api/NaverBookAPIService.java | 5 ++-- 7 files changed, 54 insertions(+), 47 deletions(-) create mode 100644 src/main/java/com/fastcampus/book_bot/dto/api/BookDTO.java diff --git a/src/main/java/com/fastcampus/book_bot/controller/BookController.java b/src/main/java/com/fastcampus/book_bot/controller/BookController.java index 20d40dd..c11f505 100644 --- a/src/main/java/com/fastcampus/book_bot/controller/BookController.java +++ b/src/main/java/com/fastcampus/book_bot/controller/BookController.java @@ -4,11 +4,7 @@ import com.fastcampus.book_bot.service.api.ApiToMySQLService; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import reactor.core.publisher.Mono; +import org.springframework.web.bind.annotation.*; import java.util.List; @@ -23,16 +19,16 @@ public BookController(ApiToMySQLService apiToMySQLService) { this.apiToMySQLService = apiToMySQLService; } - @PostMapping("/search") - public Mono>> searchAndSaveBooks( + @GetMapping("/search") + public ResponseEntity searchAndSaveBooks( @RequestParam String query, @RequestParam(defaultValue = "1") int start, @RequestParam(defaultValue = "10") int display) { log.info("도서 검색 요청: query={}, start={}, display={}", query, start, display); - return apiToMySQLService.searchAndSaveBooks(query, start, display) - .map(ResponseEntity::ok) - .onErrorReturn(ResponseEntity.internalServerError().build()); + List bookList = apiToMySQLService.searchAndSaveBooks(query, start, display); + + return ResponseEntity.ok("도서 저장 완료: " + bookList.size()); } } diff --git a/src/main/java/com/fastcampus/book_bot/domain/Book.java b/src/main/java/com/fastcampus/book_bot/domain/Book.java index 52c5ae7..be922c9 100644 --- a/src/main/java/com/fastcampus/book_bot/domain/Book.java +++ b/src/main/java/com/fastcampus/book_bot/domain/Book.java @@ -10,6 +10,7 @@ import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.LocalDateTime; +import java.time.LocalDate; @Entity @Table(name = "books") @@ -25,10 +26,10 @@ public class Book { @Column(name = "BOOK_ID", nullable = false, updatable = false) private Long bookId; - @Column(name = "BOOK_TITLE", length = 100, nullable = false) + @Column(name = "BOOK_TITLE", length = 500, nullable = false) private String bookTitle; - @Column(name = "BOOK_AUTHOR", length = 200) + @Column(name = "BOOK_AUTHOR", length = 500) private String bookAuthor; @Column(name = "BOOK_LINK", length = 200) @@ -41,13 +42,13 @@ public class Book { private String bookPublisher; @Column(name = "BOOK_ISBN") - private Integer bookIsbn; + private Long bookIsbn; - @Column(name = "BOOK_DESCRIPTION", length = 500) + @Column(name = "BOOK_DESCRIPTION", length = 2000) private String bookDescription; @Column(name = "BOOK_PUBDATE") - private LocalDateTime bookPubdate; + private LocalDate bookPubdate; @Column(name = "BOOK_DISCOUNT") private Integer bookDiscount; diff --git a/src/main/java/com/fastcampus/book_bot/dto/api/BookDTO.java b/src/main/java/com/fastcampus/book_bot/dto/api/BookDTO.java new file mode 100644 index 0000000..328afde --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/dto/api/BookDTO.java @@ -0,0 +1,23 @@ +package com.fastcampus.book_bot.dto.api; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; + +import java.time.LocalDate; + +@Data +public class BookDTO { + + private String title; + private String link; + private String image; + private String author; + private Integer discount; + private String publisher; + private Long isbn; + private String description; + + @JsonFormat(pattern = "yyyyMMdd") + private LocalDate pubdate; + +} diff --git a/src/main/java/com/fastcampus/book_bot/dto/api/NaverBookResponseDTO.java b/src/main/java/com/fastcampus/book_bot/dto/api/NaverBookResponseDTO.java index c78cdc5..88d88af 100644 --- a/src/main/java/com/fastcampus/book_bot/dto/api/NaverBookResponseDTO.java +++ b/src/main/java/com/fastcampus/book_bot/dto/api/NaverBookResponseDTO.java @@ -10,19 +10,5 @@ public class NaverBookResponseDTO { private int total; private int start; private int display; - private NaverBookItemDTO[] items; - - @Data - public static class NaverBookItemDTO { - - private String title; - private String link; - private String image; - private String author; - private int discount; - private String publisher; - private int isbn; - private String description; - private LocalDateTime pubdate; - } + private BookDTO[] items; } diff --git a/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java b/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java index f148bb9..813e68c 100644 --- a/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java +++ b/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java @@ -9,5 +9,5 @@ @Repository public interface BookRepository extends JpaRepository { - Optional findByBookIsbn(Integer bookIsbn); + Optional findByBookIsbn(Long bookIsbn); } \ No newline at end of file diff --git a/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java b/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java index ceb216d..c5b9887 100644 --- a/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java +++ b/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java @@ -1,14 +1,15 @@ package com.fastcampus.book_bot.service.api; import com.fastcampus.book_bot.domain.Book; +import com.fastcampus.book_bot.dto.api.BookDTO; import com.fastcampus.book_bot.dto.api.NaverBookResponseDTO; import com.fastcampus.book_bot.repository.BookRepository; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import reactor.core.publisher.Mono; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Optional; @@ -32,24 +33,22 @@ public ApiToMySQLService(BookRepository bookRepository, NaverBookAPIService nave this.naverBookAPIService = naverBookAPIService; } - @Transactional - public Mono> searchAndSaveBooks(String query, int start, int display) { - return naverBookAPIService.searchBooks(query, start, display) - .map(this::saveBooks) - .doOnNext(books -> log.info("총 {} 건의 도서가 저장되었습니다.", books.size())) - .doOnError(error -> log.error("도서 검색 및 저장 중 오류 발생: {}", error.getMessage())); + public List searchAndSaveBooks(String query, int start, int display) { + NaverBookResponseDTO response = naverBookAPIService.searchBooks(query, start, display); + + if (response.getItems() == null || response.getItems().length == 0) { + log.warn("조건에 맞는 도서가 없습니다. 검색어: {}", query); + return Collections.emptyList(); + } + + return saveBooks(response); } @Transactional public List saveBooks(NaverBookResponseDTO response) { List savedBooks = new ArrayList<>(); - if (response.getItems() == null || response.getItems().length == 0) { - log.warn("API 응답에 책 정보가 없습니다."); - return savedBooks; - } - - for (NaverBookResponseDTO.NaverBookItemDTO item : response.getItems()) { + for (BookDTO item : response.getItems()) { try { Book book = convertToBook(item); @@ -60,6 +59,7 @@ public List saveBooks(NaverBookResponseDTO response) { } catch (Exception e) { { log.error("도서 저장 중 오류 발생: {}, 도서: {}", e.getMessage(), item.getTitle()); + return Collections.emptyList(); } } } @@ -67,7 +67,7 @@ public List saveBooks(NaverBookResponseDTO response) { return savedBooks; } - private Book convertToBook(NaverBookResponseDTO.NaverBookItemDTO item) { + private Book convertToBook(BookDTO item) { return Book.builder() .bookTitle(item.getTitle()) .bookAuthor(item.getAuthor()) diff --git a/src/main/java/com/fastcampus/book_bot/service/api/NaverBookAPIService.java b/src/main/java/com/fastcampus/book_bot/service/api/NaverBookAPIService.java index 98c4b52..d2ead3b 100644 --- a/src/main/java/com/fastcampus/book_bot/service/api/NaverBookAPIService.java +++ b/src/main/java/com/fastcampus/book_bot/service/api/NaverBookAPIService.java @@ -31,7 +31,7 @@ public NaverBookAPIService(WebClient webClient) { this.webClient = webClient; } - public Mono searchBooks(String query, int start, int display) { + public NaverBookResponseDTO searchBooks(String query, int start, int display) { return webClient.get() .uri(uriBuilder -> uriBuilder.path("/book.json") .queryParam("query", query) @@ -55,6 +55,7 @@ public Mono searchBooks(String query, int start, int displ .timeout(Duration.ofSeconds(30)) .doOnNext(response -> log.debug("API 응답 성공: 총 {} 건", response.getTotal())) .doOnError(error -> log.error("네이버 도서 API 비동기 호출 실패: {}", error.getMessage())) - .onErrorReturn(new NaverBookResponseDTO()); + .onErrorReturn(new NaverBookResponseDTO()) + .block(Duration.ofSeconds(30)); } } From 770eda2f9bccf73bba6b1bc93966f7871eee59c4 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Thu, 7 Aug 2025 15:13:33 +0900 Subject: [PATCH 06/27] =?UTF-8?q?update=20:=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../book_bot/common/response/ApiResponse.java | 53 +++++++++++++++++++ .../book_bot/controller/BookController.java | 15 ++++-- .../com/fastcampus/book_bot/domain/Book.java | 2 +- .../fastcampus/book_bot/dto/api/BookDTO.java | 1 - .../service/api/ApiToMySQLService.java | 19 ++++--- 5 files changed, 74 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/fastcampus/book_bot/common/response/ApiResponse.java diff --git a/src/main/java/com/fastcampus/book_bot/common/response/ApiResponse.java b/src/main/java/com/fastcampus/book_bot/common/response/ApiResponse.java new file mode 100644 index 0000000..3f6a6c7 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/common/response/ApiResponse.java @@ -0,0 +1,53 @@ +package com.fastcampus.book_bot.common.response; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ApiResponse { + + /* ApiResponse 클래스는 API 응답의 기본 구조를 정의한다. + * 성공 여부, 메시지, 데이터, 에러 코드 생성 + * 성공 응답과 에러 응답을 생성하는 정적 메서드를 제공한다. + * */ + + private Boolean success; + private String message; + private T data; + private String errorCode; + + /* 성공 응답 (응답 데이터) */ + public static ApiResponse success(T data) { + return ApiResponse.builder() + .success(true) + .data(data) + .build(); + } + + /* 성공 응답 (응답 데이터, 메시지) */ + public static ApiResponse success(T data, String message) { + return ApiResponse.builder() + .success(true) + .message(message) + .data(data) + .build(); + } + + /* 에러 응답 (메시지) */ + public static ApiResponse error(String message) { + return ApiResponse.builder() + .success(false) + .message(message) + .build(); + } + + /* 에러 응답 (메세지, 에러 코드) */ + public static ApiResponse error(String message, String errorCode) { + return ApiResponse.builder() + .success(false) + .message(message) + .errorCode(errorCode) + .build(); + } +} diff --git a/src/main/java/com/fastcampus/book_bot/controller/BookController.java b/src/main/java/com/fastcampus/book_bot/controller/BookController.java index c11f505..ee52766 100644 --- a/src/main/java/com/fastcampus/book_bot/controller/BookController.java +++ b/src/main/java/com/fastcampus/book_bot/controller/BookController.java @@ -1,6 +1,8 @@ package com.fastcampus.book_bot.controller; -import com.fastcampus.book_bot.domain.Book; +import com.fastcampus.book_bot.common.response.ApiResponse; +import com.fastcampus.book_bot.dto.api.BookDTO; +import com.fastcampus.book_bot.dto.api.NaverBookResponseDTO; import com.fastcampus.book_bot.service.api.ApiToMySQLService; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; @@ -20,15 +22,20 @@ public BookController(ApiToMySQLService apiToMySQLService) { } @GetMapping("/search") - public ResponseEntity searchAndSaveBooks( + public ResponseEntity> searchAndSaveBooks( @RequestParam String query, @RequestParam(defaultValue = "1") int start, @RequestParam(defaultValue = "10") int display) { log.info("도서 검색 요청: query={}, start={}, display={}", query, start, display); - List bookList = apiToMySQLService.searchAndSaveBooks(query, start, display); + ApiResponse apiResponse = apiToMySQLService.searchAndSaveBooks(query, start, display); + + if (apiResponse.getSuccess()) { + return ResponseEntity.ok(apiResponse); + } else { + return ResponseEntity.badRequest().body(apiResponse); + } - return ResponseEntity.ok("도서 저장 완료: " + bookList.size()); } } diff --git a/src/main/java/com/fastcampus/book_bot/domain/Book.java b/src/main/java/com/fastcampus/book_bot/domain/Book.java index be922c9..8af135f 100644 --- a/src/main/java/com/fastcampus/book_bot/domain/Book.java +++ b/src/main/java/com/fastcampus/book_bot/domain/Book.java @@ -44,7 +44,7 @@ public class Book { @Column(name = "BOOK_ISBN") private Long bookIsbn; - @Column(name = "BOOK_DESCRIPTION", length = 2000) + @Column(name = "BOOK_DESCRIPTION", length = 5000) private String bookDescription; @Column(name = "BOOK_PUBDATE") diff --git a/src/main/java/com/fastcampus/book_bot/dto/api/BookDTO.java b/src/main/java/com/fastcampus/book_bot/dto/api/BookDTO.java index 328afde..eb7338a 100644 --- a/src/main/java/com/fastcampus/book_bot/dto/api/BookDTO.java +++ b/src/main/java/com/fastcampus/book_bot/dto/api/BookDTO.java @@ -16,7 +16,6 @@ public class BookDTO { private String publisher; private Long isbn; private String description; - @JsonFormat(pattern = "yyyyMMdd") private LocalDate pubdate; diff --git a/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java b/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java index c5b9887..5097535 100644 --- a/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java +++ b/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java @@ -1,5 +1,6 @@ package com.fastcampus.book_bot.service.api; +import com.fastcampus.book_bot.common.response.ApiResponse; import com.fastcampus.book_bot.domain.Book; import com.fastcampus.book_bot.dto.api.BookDTO; import com.fastcampus.book_bot.dto.api.NaverBookResponseDTO; @@ -33,38 +34,36 @@ public ApiToMySQLService(BookRepository bookRepository, NaverBookAPIService nave this.naverBookAPIService = naverBookAPIService; } - public List searchAndSaveBooks(String query, int start, int display) { + @Transactional + public ApiResponse searchAndSaveBooks(String query, int start, int display) { NaverBookResponseDTO response = naverBookAPIService.searchBooks(query, start, display); if (response.getItems() == null || response.getItems().length == 0) { log.warn("조건에 맞는 도서가 없습니다. 검색어: {}", query); - return Collections.emptyList(); + return ApiResponse.error("검색 결과가 없습니다."); } - return saveBooks(response); + saveBooks(response); + + return ApiResponse.success(response, "도서 저장 완료"); } @Transactional - public List saveBooks(NaverBookResponseDTO response) { - List savedBooks = new ArrayList<>(); + protected void saveBooks(NaverBookResponseDTO response) { for (BookDTO item : response.getItems()) { try { Book book = convertToBook(item); if (!isDuplicateBook(book)) { - Book savedBook = bookRepository.save(book); - savedBooks.add(savedBook); + bookRepository.save(book); } } catch (Exception e) { { log.error("도서 저장 중 오류 발생: {}, 도서: {}", e.getMessage(), item.getTitle()); - return Collections.emptyList(); } } } - - return savedBooks; } private Book convertToBook(BookDTO item) { From a270e48fd1d511c8bb0a24ba7bf20a8b14c11cfc Mon Sep 17 00:00:00 2001 From: JiHoon Date: Fri, 8 Aug 2025 11:10:16 +0900 Subject: [PATCH 07/27] =?UTF-8?q?update=20:=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EC=85=98=20=EB=B0=8F=20=EB=B7=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- .../book_bot/controller/MainController.java | 16 + .../NaverBookApiController.java} | 9 +- .../controller/book/BookController.java | 44 ++ .../book_bot/dto/book/SearchDTO.java | 38 ++ .../book_bot/repository/BookRepository.java | 7 + .../service/book/BookSearchService.java | 41 ++ src/main/resources/templates/book/search.html | 432 ++++++++++++++++++ .../templates/common/navigation.html | 20 + .../templates/common/popularKeywords.html | 15 + .../templates/common/recentlyViewed.html | 20 + src/main/resources/templates/index.html | 127 +++++ 12 files changed, 764 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/fastcampus/book_bot/controller/MainController.java rename src/main/java/com/fastcampus/book_bot/controller/{BookController.java => api/NaverBookApiController.java} (84%) create mode 100644 src/main/java/com/fastcampus/book_bot/controller/book/BookController.java create mode 100644 src/main/java/com/fastcampus/book_bot/dto/book/SearchDTO.java create mode 100644 src/main/java/com/fastcampus/book_bot/service/book/BookSearchService.java create mode 100644 src/main/resources/templates/book/search.html create mode 100644 src/main/resources/templates/common/navigation.html create mode 100644 src/main/resources/templates/common/popularKeywords.html create mode 100644 src/main/resources/templates/common/recentlyViewed.html create mode 100644 src/main/resources/templates/index.html diff --git a/build.gradle b/build.gradle index ce265ca..99f8320 100644 --- a/build.gradle +++ b/build.gradle @@ -27,8 +27,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-batch' implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.springframework.boot:spring-boot-starter-validation' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/com/fastcampus/book_bot/controller/MainController.java b/src/main/java/com/fastcampus/book_bot/controller/MainController.java new file mode 100644 index 0000000..8254ba4 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/controller/MainController.java @@ -0,0 +1,16 @@ +package com.fastcampus.book_bot.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +@Slf4j +public class MainController { + + @GetMapping + public String main() { + + return "index"; + } +} diff --git a/src/main/java/com/fastcampus/book_bot/controller/BookController.java b/src/main/java/com/fastcampus/book_bot/controller/api/NaverBookApiController.java similarity index 84% rename from src/main/java/com/fastcampus/book_bot/controller/BookController.java rename to src/main/java/com/fastcampus/book_bot/controller/api/NaverBookApiController.java index ee52766..7012df2 100644 --- a/src/main/java/com/fastcampus/book_bot/controller/BookController.java +++ b/src/main/java/com/fastcampus/book_bot/controller/api/NaverBookApiController.java @@ -1,23 +1,20 @@ -package com.fastcampus.book_bot.controller; +package com.fastcampus.book_bot.controller.api; import com.fastcampus.book_bot.common.response.ApiResponse; -import com.fastcampus.book_bot.dto.api.BookDTO; import com.fastcampus.book_bot.dto.api.NaverBookResponseDTO; import com.fastcampus.book_bot.service.api.ApiToMySQLService; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.util.List; - @RestController @RequestMapping("/api/books") @Slf4j -public class BookController { +public class NaverBookApiController { private final ApiToMySQLService apiToMySQLService; - public BookController(ApiToMySQLService apiToMySQLService) { + public NaverBookApiController(ApiToMySQLService apiToMySQLService) { this.apiToMySQLService = apiToMySQLService; } diff --git a/src/main/java/com/fastcampus/book_bot/controller/book/BookController.java b/src/main/java/com/fastcampus/book_bot/controller/book/BookController.java new file mode 100644 index 0000000..3591476 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/controller/book/BookController.java @@ -0,0 +1,44 @@ +package com.fastcampus.book_bot.controller.book; + +import com.fastcampus.book_bot.domain.Book; +import com.fastcampus.book_bot.dto.book.SearchDTO; +import com.fastcampus.book_bot.service.book.BookSearchService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; + +@Controller +@Slf4j +public class BookController { + + private final BookSearchService bookSearchService; + + public BookController(BookSearchService bookSearchService) { + this.bookSearchService = bookSearchService; + } + + @GetMapping("/search") + public String searchBooks(@ModelAttribute SearchDTO searchDTO, + @PageableDefault(size = 10, sort = "bookPubdate", direction = Sort.Direction.DESC) Pageable pageable, + Model model) { + + Page searchResult = bookSearchService.searchBooks( + searchDTO.getKeyword(), + searchDTO.getSearchType(), + pageable + ); + + searchDTO.setSearchResult(searchResult); + searchDTO.setPageInfo(pageable); + + model.addAttribute("search", searchDTO); + + return "book/search"; + } +} diff --git a/src/main/java/com/fastcampus/book_bot/dto/book/SearchDTO.java b/src/main/java/com/fastcampus/book_bot/dto/book/SearchDTO.java new file mode 100644 index 0000000..3df2ce7 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/dto/book/SearchDTO.java @@ -0,0 +1,38 @@ +package com.fastcampus.book_bot.dto.book; + +import com.fastcampus.book_bot.domain.Book; +import lombok.Data; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +@Data +public class SearchDTO { + + /* 요청 필드 */ + private String keyword; + private String searchType = "all"; + + /* 응답 필드 */ + private Page searchResult; + private Integer pageSize; + private String sortProperty; + private String sortDirection; + + public void setPageInfo(Pageable pageable) { + this.pageSize = pageable.getPageSize(); + this.sortProperty = getSortProperty(pageable); + this.sortDirection = getSortDirection(pageable); + } + + private String getSortProperty(Pageable pageable) { + return pageable.getSort().iterator().hasNext() ? + pageable.getSort().iterator().next().getProperty() : "bookPubdate"; + } + + private String getSortDirection(Pageable pageable) { + return pageable.getSort().iterator().hasNext() ? + pageable.getSort().iterator().next().getDirection().name() : "DESC"; + } + + +} \ No newline at end of file diff --git a/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java b/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java index 813e68c..1d85132 100644 --- a/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java +++ b/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java @@ -1,6 +1,8 @@ package com.fastcampus.book_bot.repository; import com.fastcampus.book_bot.domain.Book; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -10,4 +12,9 @@ public interface BookRepository extends JpaRepository { Optional findByBookIsbn(Long bookIsbn); + Page findByBookTitleContaining(String bookTitle, Pageable pageable); + Page findByBookAuthorContaining(String bookAuthor, Pageable pageable); + Page findByBookPublisherContaining(String bookPublisher, Pageable pageable); + Page findByBookTitleContainingOrBookAuthorContainingOrBookPublisherContaining(String bookTitle, String bookAuthor, String bookPublisher, Pageable pageable); + } \ No newline at end of file diff --git a/src/main/java/com/fastcampus/book_bot/service/book/BookSearchService.java b/src/main/java/com/fastcampus/book_bot/service/book/BookSearchService.java new file mode 100644 index 0000000..db3986f --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/service/book/BookSearchService.java @@ -0,0 +1,41 @@ +package com.fastcampus.book_bot.service.book; + +import com.fastcampus.book_bot.domain.Book; +import com.fastcampus.book_bot.repository.BookRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Slf4j +public class BookSearchService { + + private final BookRepository bookRepository; + + public BookSearchService(BookRepository bookRepository) { + this.bookRepository = bookRepository; + } + + @Transactional(readOnly = true) + public Page searchBooks(String keyword, String searchType, Pageable pageable) { + + try { + switch (searchType) { + case "title": + return bookRepository.findByBookTitleContaining(keyword, pageable); + case "author": + return bookRepository.findByBookAuthorContaining(keyword, pageable); + case "publisher": + return bookRepository.findByBookPublisherContaining(keyword, pageable); + case "all": + default: + return bookRepository.findByBookTitleContainingOrBookAuthorContainingOrBookPublisherContaining(keyword, keyword, keyword, pageable); + } + } catch (Exception e) { + log.warn("도서 검색 중 오류 발생! 조건: {}, 키워드 {}", searchType, keyword); + return Page.empty(pageable); + } + } +} diff --git a/src/main/resources/templates/book/search.html b/src/main/resources/templates/book/search.html new file mode 100644 index 0000000..1c52af2 --- /dev/null +++ b/src/main/resources/templates/book/search.html @@ -0,0 +1,432 @@ + + + + + + 검색 결과 - 온라인 서점 + + + + + + +
+ + +
+
+
+ + + +
+
+
+ +
+
+ +
+ +
+
+ +
+
+
+

+ '검색어' 검색 결과 +

+

+ 총 0개의 책을 찾았습니다. + + (검색타입 검색) + +

+
+
+ + + + + + +
+
+
+ + +
+
+
+ +
+
책 제목
+

저자: 작가명

+

출판사: 출판사명

+ + +

+ 출간일: 2024.01.01 +

+ + +

+ 책 설명이 여기에 표시됩니다... +

+ +
+ +
+

15,000원

+
+ + +
+
+
+
+
+ + +
+
+ 1-10 + of 100 +
+ + +
+ + +
+
+ +
+

검색 결과가 없습니다

+

+ '검색어'에 대한 검색 결과를 찾을 수 없습니다.
+ 다른 검색어로 다시 시도해보세요. +

+
+
검색 팁:
+
    +
  • • 검색어의 철자를 확인해보세요
  • +
  • • 더 간단한 검색어를 사용해보세요
  • +
  • • 검색 범위를 '전체'로 변경해보세요
  • +
  • • 저자명이나 출판사로 검색해보세요
  • +
+
+
+
+
+
+ + + +
+
+ + + + + \ No newline at end of file diff --git a/src/main/resources/templates/common/navigation.html b/src/main/resources/templates/common/navigation.html new file mode 100644 index 0000000..ba12dbd --- /dev/null +++ b/src/main/resources/templates/common/navigation.html @@ -0,0 +1,20 @@ + + \ No newline at end of file diff --git a/src/main/resources/templates/common/popularKeywords.html b/src/main/resources/templates/common/popularKeywords.html new file mode 100644 index 0000000..ea82b3e --- /dev/null +++ b/src/main/resources/templates/common/popularKeywords.html @@ -0,0 +1,15 @@ + + \ No newline at end of file diff --git a/src/main/resources/templates/common/recentlyViewed.html b/src/main/resources/templates/common/recentlyViewed.html new file mode 100644 index 0000000..73d464c --- /dev/null +++ b/src/main/resources/templates/common/recentlyViewed.html @@ -0,0 +1,20 @@ + +
+
👀 최근 조회한 책
+ +
\ No newline at end of file diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html new file mode 100644 index 0000000..3729b18 --- /dev/null +++ b/src/main/resources/templates/index.html @@ -0,0 +1,127 @@ + + + + + + 온라인 서점 - 메인 + + + + + +
+ + +
+
+
+ + + +
+
+
+ +
+
+ +
+ +
+
+

📈 이달의 베스트셀러

+
+
+
+ +
+
+ + 1위 + + 소설 +
+
책 제목
+

저자

+

+ 15000원 +

+ 자세히 보기 +
+
+
+
+
+
+
+ + + + +
+
+ + + + \ No newline at end of file From 5ebd19be39725c59a7938091c8ac2436b775af7b Mon Sep 17 00:00:00 2001 From: JiHoon Date: Fri, 8 Aug 2025 11:27:57 +0900 Subject: [PATCH 08/27] =?UTF-8?q?update=20:=20=EB=8F=84=EC=84=9C=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/book/BookController.java | 15 + .../service/book/BookSearchService.java | 20 + src/main/resources/templates/book/detail.html | 347 ++++++++++++++++++ 3 files changed, 382 insertions(+) create mode 100644 src/main/resources/templates/book/detail.html diff --git a/src/main/java/com/fastcampus/book_bot/controller/book/BookController.java b/src/main/java/com/fastcampus/book_bot/controller/book/BookController.java index 3591476..5acdb1f 100644 --- a/src/main/java/com/fastcampus/book_bot/controller/book/BookController.java +++ b/src/main/java/com/fastcampus/book_bot/controller/book/BookController.java @@ -12,6 +12,9 @@ import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; + +import java.util.Optional; @Controller @Slf4j @@ -41,4 +44,16 @@ public String searchBooks(@ModelAttribute SearchDTO searchDTO, return "book/search"; } + + @GetMapping("/book/{bookId}") + public String bookDetail(@PathVariable Long bookId, Model model) { + + Optional book = bookSearchService.getBookById(bookId); + if (book.isPresent()) { + model.addAttribute("book", book.get()); + return "book/detail"; + } else { + return "error/404"; + } + } } diff --git a/src/main/java/com/fastcampus/book_bot/service/book/BookSearchService.java b/src/main/java/com/fastcampus/book_bot/service/book/BookSearchService.java index db3986f..c112c60 100644 --- a/src/main/java/com/fastcampus/book_bot/service/book/BookSearchService.java +++ b/src/main/java/com/fastcampus/book_bot/service/book/BookSearchService.java @@ -8,6 +8,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Optional; + @Service @Slf4j public class BookSearchService { @@ -38,4 +40,22 @@ public Page searchBooks(String keyword, String searchType, Pageable pageab return Page.empty(pageable); } } + + @Transactional(readOnly = true) + public Optional getBookById(Long bookId) { + log.info("도서 상세 조회 - bookId: {}", bookId); + + try { + Optional book = bookRepository.findById(bookId); + if (book.isPresent()) { + return book; + } else { + log.warn("도서를 찾을 수 없습니다. ID: {}", bookId); + return Optional.empty(); + } + } catch (Exception e) { + log.error("도서 검색 중 오류 발생! 도서 ID: {}", bookId); + return Optional.empty(); + } + } } diff --git a/src/main/resources/templates/book/detail.html b/src/main/resources/templates/book/detail.html new file mode 100644 index 0000000..6cff2f2 --- /dev/null +++ b/src/main/resources/templates/book/detail.html @@ -0,0 +1,347 @@ + + + + + + 도서 상세 - 온라인 서점 + + + + + + +
+ + +
+
+ + + +
+ +
+
+ +
+
+ + +
+ +

도서 제목

+ +

+ + 저자: 저자명 +

+ +

+ + 출판사: 출판사명 +

+ + +
+ + 가격 +
+ + +
+ + + + + + + + + + + + + + + + + + + +
ISBNISBN
출간일출간일
등록일등록일
수정일수정일
+
+ + +
+ + 구매하기 + + + + + +
+
+
+ + +
+
+

도서 소개

+
+ 도서에 대한 상세한 설명이 여기에 표시됩니다... +
+
+
+ + +
+
+ +
+
+
+
+ + + + + + + + \ No newline at end of file From cfcf75f011ab59c77515eb4b1e2b45bfc08de689 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Sun, 10 Aug 2025 16:15:00 +0900 Subject: [PATCH 09/27] =?UTF-8?q?update=20:=20redis=20=EB=B0=8F=20flyway?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EC=A0=84=20push?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++ build.gradle | 2 + .../controller/book/BookController.java | 2 +- .../book_bot/domain/{ => book}/Book.java | 2 +- .../fastcampus/book_bot/domain/user/User.java | 46 +++++++++++++++++++ .../book_bot/domain/user/UserGrade.java | 45 ++++++++++++++++++ .../book_bot/dto/book/SearchDTO.java | 2 +- .../book_bot/repository/BookRepository.java | 2 +- .../service/api/ApiToMySQLService.java | 5 +- .../service/book/BookSearchService.java | 2 +- 10 files changed, 102 insertions(+), 9 deletions(-) rename src/main/java/com/fastcampus/book_bot/domain/{ => book}/Book.java (97%) create mode 100644 src/main/java/com/fastcampus/book_bot/domain/user/User.java create mode 100644 src/main/java/com/fastcampus/book_bot/domain/user/UserGrade.java diff --git a/.gitignore b/.gitignore index dabf52d..ae5c741 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,6 @@ out/ ### VS Code ### .vscode/ + +### application.yml ### +src/main/resources/application.yml diff --git a/build.gradle b/build.gradle index 99f8320..b167e2e 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-mysql' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' diff --git a/src/main/java/com/fastcampus/book_bot/controller/book/BookController.java b/src/main/java/com/fastcampus/book_bot/controller/book/BookController.java index 5acdb1f..e0b0b28 100644 --- a/src/main/java/com/fastcampus/book_bot/controller/book/BookController.java +++ b/src/main/java/com/fastcampus/book_bot/controller/book/BookController.java @@ -1,6 +1,6 @@ package com.fastcampus.book_bot.controller.book; -import com.fastcampus.book_bot.domain.Book; +import com.fastcampus.book_bot.domain.book.Book; import com.fastcampus.book_bot.dto.book.SearchDTO; import com.fastcampus.book_bot.service.book.BookSearchService; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/fastcampus/book_bot/domain/Book.java b/src/main/java/com/fastcampus/book_bot/domain/book/Book.java similarity index 97% rename from src/main/java/com/fastcampus/book_bot/domain/Book.java rename to src/main/java/com/fastcampus/book_bot/domain/book/Book.java index 8af135f..41d4fd3 100644 --- a/src/main/java/com/fastcampus/book_bot/domain/Book.java +++ b/src/main/java/com/fastcampus/book_bot/domain/book/Book.java @@ -1,4 +1,4 @@ -package com.fastcampus.book_bot.domain; +package com.fastcampus.book_bot.domain.book; import jakarta.persistence.*; import jakarta.persistence.Id; diff --git a/src/main/java/com/fastcampus/book_bot/domain/user/User.java b/src/main/java/com/fastcampus/book_bot/domain/user/User.java new file mode 100644 index 0000000..65ee4c5 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/domain/user/User.java @@ -0,0 +1,46 @@ +package com.fastcampus.book_bot.domain.user; + +import jakarta.persistence.*; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Size; +import org.hibernate.annotations.Check; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "User") +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "USER_ID", nullable = false, updatable = false) + private Long UserId; + + @Column(name = "USER_EMAIL", nullable = false) + @Email(message = "유효한 이메일 형식이어야 합니다.") + private String UserEmail; + + @Column(name = "USER_PASSWORD", length = 12, nullable = false) + @Size(min = 8, max = 12, message = "비밀번호는 8~12자여야 합니다.") + private String UserPassword; + + @Column(name = "USER_NAME", length = 50, nullable = false) + private String userName; + + @Column(name = "USER_PHONE", length = 20) + private String userPhone; + + @Column(name = "USER_STATUS") + @Check(constraints = "USER_STATUS IN ('ACTIVE', 'INACTIVE', 'SUSPENDED')") + private String userStatus = "ACTIVE"; + + @CreatedDate + @Column(name = "CREATED_AT", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "UPDATED_AT") + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/fastcampus/book_bot/domain/user/UserGrade.java b/src/main/java/com/fastcampus/book_bot/domain/user/UserGrade.java new file mode 100644 index 0000000..2029110 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/domain/user/UserGrade.java @@ -0,0 +1,45 @@ +package com.fastcampus.book_bot.domain.user; + +import jakarta.persistence.*; +import org.hibernate.annotations.Check; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "User_Grade") +public class UserGrade { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "GRADE_ID", updatable = false, nullable = false) + private Long gradeId; + + @Column(name = "GRADE_NAME", nullable = false) + @Check(constraints = "GRADE_NAME IN ('BRONZE', 'SILVER', 'GOLD', 'PLATINUM')") + private String gradeName = "BRONZE"; + + @Column(name = "ORDER_COUNT") + private Integer orderCount = 0; + + @Column(name = "MIN_USAGE", nullable = false) + private Integer MinUsage = 0; + + @Column(name = "DISCOUNT", nullable = false) + private Float discount = 0.03f; + + @Column(name = "MILEAGE_RATE") + private Float mileageRate = 0.03f; + + @CreatedDate + @Column(name = "CREATED_AT", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "UPDATED_AT") + private LocalDateTime updatedAt; + + @Column(name = "UPDATED_BY") + private Integer updatedBy; +} diff --git a/src/main/java/com/fastcampus/book_bot/dto/book/SearchDTO.java b/src/main/java/com/fastcampus/book_bot/dto/book/SearchDTO.java index 3df2ce7..ab29e82 100644 --- a/src/main/java/com/fastcampus/book_bot/dto/book/SearchDTO.java +++ b/src/main/java/com/fastcampus/book_bot/dto/book/SearchDTO.java @@ -1,6 +1,6 @@ package com.fastcampus.book_bot.dto.book; -import com.fastcampus.book_bot.domain.Book; +import com.fastcampus.book_bot.domain.book.Book; import lombok.Data; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; diff --git a/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java b/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java index 1d85132..dff1a87 100644 --- a/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java +++ b/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java @@ -1,6 +1,6 @@ package com.fastcampus.book_bot.repository; -import com.fastcampus.book_bot.domain.Book; +import com.fastcampus.book_bot.domain.book.Book; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java b/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java index 5097535..ebdc2a5 100644 --- a/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java +++ b/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java @@ -1,7 +1,7 @@ package com.fastcampus.book_bot.service.api; import com.fastcampus.book_bot.common.response.ApiResponse; -import com.fastcampus.book_bot.domain.Book; +import com.fastcampus.book_bot.domain.book.Book; import com.fastcampus.book_bot.dto.api.BookDTO; import com.fastcampus.book_bot.dto.api.NaverBookResponseDTO; import com.fastcampus.book_bot.repository.BookRepository; @@ -9,9 +9,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; import java.util.Optional; @Service diff --git a/src/main/java/com/fastcampus/book_bot/service/book/BookSearchService.java b/src/main/java/com/fastcampus/book_bot/service/book/BookSearchService.java index c112c60..c4a7f29 100644 --- a/src/main/java/com/fastcampus/book_bot/service/book/BookSearchService.java +++ b/src/main/java/com/fastcampus/book_bot/service/book/BookSearchService.java @@ -1,6 +1,6 @@ package com.fastcampus.book_bot.service.book; -import com.fastcampus.book_bot.domain.Book; +import com.fastcampus.book_bot.domain.book.Book; import com.fastcampus.book_bot.repository.BookRepository; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; From 65b9a075291a93d997b3d3554115a296e3dc5aea Mon Sep 17 00:00:00 2001 From: JiHoon Date: Sun, 10 Aug 2025 16:23:02 +0900 Subject: [PATCH 10/27] =?UTF-8?q?update=20:=20redis=20=EB=B0=8F=20flyway?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EC=A0=84=20push?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mysql-docker/{mysql-compose.yml => docker-compose.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename mysql-docker/{mysql-compose.yml => docker-compose.yml} (100%) diff --git a/mysql-docker/mysql-compose.yml b/mysql-docker/docker-compose.yml similarity index 100% rename from mysql-docker/mysql-compose.yml rename to mysql-docker/docker-compose.yml From 15c7cf62b03f62b66fd484ebaaa39a292b855faa Mon Sep 17 00:00:00 2001 From: JiHoon Date: Sun, 10 Aug 2025 21:56:35 +0900 Subject: [PATCH 11/27] =?UTF-8?q?update=20:=20redis=20=EB=B0=8F=20flyway?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mysql-docker/Dockerfile | 12 -- mysql-docker/docker-compose.yml | 57 +++++++- .../fastcampus/book_bot/domain/book/Book.java | 43 +++--- .../fastcampus/book_bot/domain/user/User.java | 59 ++++++-- .../book_bot/domain/user/UserGrade.java | 32 +++-- .../book_bot/repository/BookRepository.java | 6 +- .../service/api/ApiToMySQLService.java | 12 +- .../service/book/BookSearchService.java | 4 +- .../migration/V1__create_initial_schema.sql | 136 ++++++++++++++++++ 9 files changed, 290 insertions(+), 71 deletions(-) delete mode 100644 mysql-docker/Dockerfile create mode 100644 src/main/resources/db/migration/V1__create_initial_schema.sql diff --git a/mysql-docker/Dockerfile b/mysql-docker/Dockerfile deleted file mode 100644 index d415a18..0000000 --- a/mysql-docker/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM mysql:8.0 - -ENV MYSQL_ROOT_PASSWORD=1234 -ENV MYSQL_DATABASE=Book_Store -ENV MYSQL_USER=jihoon -ENV MYSQL_PASSWORD=1234 - -EXPOSE 3306 - -VOLUME ["/var/lib/mysql"] - -CMD ["mysqld", "--default-authentication-plugin=mysql_native_password"] diff --git a/mysql-docker/docker-compose.yml b/mysql-docker/docker-compose.yml index b0fbf49..1010eae 100644 --- a/mysql-docker/docker-compose.yml +++ b/mysql-docker/docker-compose.yml @@ -1,19 +1,66 @@ services: mysql: - build: . - container_name: bookstore-mysql + image: mysql:8.0 + container_name: bookshop-mysql restart: always ports: - - "3306:3306" + - "3307:3306" + environment: + MYSQL_ROOT_PASSWORD: 1234 + MYSQL_DATABASE: book_shop + MYSQL_USER: jihoon + MYSQL_PASSWORD: 1234 + command: > + --default-authentication-plugin=mysql_native_password + --character-set-server=utf8mb4 + --collation-server=utf8mb4_unicode_ci + --lower-case-table-names=0 volumes: - mysql-data:/var/lib/mysql networks: - - bookstore-network + - bookshop-network + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + timeout: 20s + retries: 5 + + redis: + image: redis:7-alpine + container_name: bookshop-redis + restart: always + ports: + - "6379:6379" + volumes: + - redis-data:/data + networks: + - bookshop-network + command: redis-server --appendonly yes + + flyway: + image: flyway/flyway:9.22.3 + container_name: bookshop-flyway + restart: "no" + volumes: + - ../src/main/resources/db/migration:/flyway/sql + networks: + - bookshop-network + depends_on: + mysql: + condition: service_healthy + command: [ + "-url=jdbc:mysql://mysql:3306/book_shop?allowPublicKeyRetrieval=true&useSSL=false&characterEncoding=UTF-8&serverTimezone=Asia/Seoul", + "-user=jihoon", + "-password=1234", + "-locations=filesystem:/flyway/sql", + "migrate" + ] volumes: mysql-data: driver: local + redis-data: + driver: local networks: - bookstore-network: + bookshop-network: driver: bridge \ No newline at end of file diff --git a/src/main/java/com/fastcampus/book_bot/domain/book/Book.java b/src/main/java/com/fastcampus/book_bot/domain/book/Book.java index 41d4fd3..71103da 100644 --- a/src/main/java/com/fastcampus/book_bot/domain/book/Book.java +++ b/src/main/java/com/fastcampus/book_bot/domain/book/Book.java @@ -6,14 +6,15 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; -import org.springframework.data.annotation.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; -import java.time.LocalDateTime; import java.time.LocalDate; +import java.time.LocalDateTime; @Entity -@Table(name = "books") +@Table(name = "`BOOKS`") @EntityListeners(AuditingEntityListener.class) @Data @Builder @@ -24,27 +25,18 @@ public class Book { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "BOOK_ID", nullable = false, updatable = false) - private Long bookId; + private Integer bookId; - @Column(name = "BOOK_TITLE", length = 500, nullable = false) - private String bookTitle; + @Column(name = "BOOK_NAME", length = 300) + private String bookName; - @Column(name = "BOOK_AUTHOR", length = 500) + @Column(name = "BOOK_AUTHOR", length = 300) private String bookAuthor; - @Column(name = "BOOK_LINK", length = 200) - private String bookLink; - - @Column(name = "BOOK_IMAGE", length = 200) - private String bookImage; - - @Column(name = "BOOK_PUBLISHER", length = 100) + @Column(name = "BOOK_PUBLISHER", length = 300) private String bookPublisher; - @Column(name = "BOOK_ISBN") - private Long bookIsbn; - - @Column(name = "BOOK_DESCRIPTION", length = 5000) + @Column(name = "BOOK_DESCRIPTION", length = 3000) private String bookDescription; @Column(name = "BOOK_PUBDATE") @@ -53,6 +45,21 @@ public class Book { @Column(name = "BOOK_DISCOUNT") private Integer bookDiscount; + @Column(name = "BOOK_LINK") + private String bookLink; + + @Column(name = "BOOK_IMAGE_PATH", length = 100) + private String bookImagePath; + + @Column(name = "BOOK_ISBN", length = 30) + private String bookIsbn; + + @Column(name = "BOOK_QUANTITY") + private Integer bookQuantity; + + @Column(name = "UPDATED_BY") + private Integer updatedBy; + @CreatedDate @Column(name = "CREATED_AT", updatable = false) private LocalDateTime createdAt; diff --git a/src/main/java/com/fastcampus/book_bot/domain/user/User.java b/src/main/java/com/fastcampus/book_bot/domain/user/User.java index 65ee4c5..2c37138 100644 --- a/src/main/java/com/fastcampus/book_bot/domain/user/User.java +++ b/src/main/java/com/fastcampus/book_bot/domain/user/User.java @@ -2,40 +2,71 @@ import jakarta.persistence.*; import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.Size; -import org.hibernate.annotations.Check; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.LocalDateTime; @Entity -@Table(name = "User") +@Table(name = "USER") +@EntityListeners(AuditingEntityListener.class) +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "USER_ID", nullable = false, updatable = false) - private Long UserId; + private Integer userId; - @Column(name = "USER_EMAIL", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "GRADE_ID", nullable = false) + private UserGrade userGrade; + + @Column(name = "USER_EMAIL", length = 30) @Email(message = "유효한 이메일 형식이어야 합니다.") - private String UserEmail; + private String userEmail; - @Column(name = "USER_PASSWORD", length = 12, nullable = false) - @Size(min = 8, max = 12, message = "비밀번호는 8~12자여야 합니다.") - private String UserPassword; + @Column(name = "USER_PASSWORD", length = 30) + private String userPassword; - @Column(name = "USER_NAME", length = 50, nullable = false) + @Column(name = "USER_NAME", length = 30) private String userName; - @Column(name = "USER_PHONE", length = 20) + @Column(name = "USER_PHONE", length = 30) private String userPhone; - @Column(name = "USER_STATUS") - @Check(constraints = "USER_STATUS IN ('ACTIVE', 'INACTIVE', 'SUSPENDED')") + @Column(name = "USER_STATUS", length = 50, nullable = false) private String userStatus = "ACTIVE"; + @Column(name = "POINT") + private Integer point; + + @Column(name = "POSTCODE") + private Integer postcode; + + @Column(name = "DEFAULT_ADDRESS", length = 200) + private String defaultAddress; + + @Column(name = "DETAIL_ADDRESS", length = 200) + private String detailAddress; + + @Column(name = "CITY", length = 50) + private String city; + + @Column(name = "PROVINCE", length = 50) + private String province; + + @Column(name = "LAST_LOGIN") + private LocalDateTime lastLogin; + @CreatedDate @Column(name = "CREATED_AT", nullable = false, updatable = false) private LocalDateTime createdAt; @@ -43,4 +74,4 @@ public class User { @LastModifiedDate @Column(name = "UPDATED_AT") private LocalDateTime updatedAt; -} +} \ No newline at end of file diff --git a/src/main/java/com/fastcampus/book_bot/domain/user/UserGrade.java b/src/main/java/com/fastcampus/book_bot/domain/user/UserGrade.java index 2029110..26a3e54 100644 --- a/src/main/java/com/fastcampus/book_bot/domain/user/UserGrade.java +++ b/src/main/java/com/fastcampus/book_bot/domain/user/UserGrade.java @@ -1,36 +1,44 @@ package com.fastcampus.book_bot.domain.user; import jakarta.persistence.*; -import org.hibernate.annotations.Check; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; import java.time.LocalDateTime; @Entity -@Table(name = "User_Grade") +@Table(name = "USER_GRADE") // 테이블명 수정 +@EntityListeners(AuditingEntityListener.class) +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor public class UserGrade { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "GRADE_ID", updatable = false, nullable = false) - private Long gradeId; + private Integer gradeId; @Column(name = "GRADE_NAME", nullable = false) - @Check(constraints = "GRADE_NAME IN ('BRONZE', 'SILVER', 'GOLD', 'PLATINUM')") private String gradeName = "BRONZE"; - @Column(name = "ORDER_COUNT") - private Integer orderCount = 0; + @Column(name = "MIN_USAGE") + private Integer minUsage; - @Column(name = "MIN_USAGE", nullable = false) - private Integer MinUsage = 0; + @Column(name = "ORDER_COUNT") + private Integer orderCount; - @Column(name = "DISCOUNT", nullable = false) - private Float discount = 0.03f; + @Column(name = "DISCOUNT") + private Float discount; @Column(name = "MILEAGE_RATE") - private Float mileageRate = 0.03f; + private Float mileageRate; @CreatedDate @Column(name = "CREATED_AT", nullable = false, updatable = false) @@ -42,4 +50,4 @@ public class UserGrade { @Column(name = "UPDATED_BY") private Integer updatedBy; -} +} \ No newline at end of file diff --git a/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java b/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java index dff1a87..4a9e3eb 100644 --- a/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java +++ b/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java @@ -11,10 +11,10 @@ @Repository public interface BookRepository extends JpaRepository { - Optional findByBookIsbn(Long bookIsbn); - Page findByBookTitleContaining(String bookTitle, Pageable pageable); + Optional findByBookIsbn(String bookIsbn); + Page findByBookNameContaining(String bookTitle, Pageable pageable); Page findByBookAuthorContaining(String bookAuthor, Pageable pageable); Page findByBookPublisherContaining(String bookPublisher, Pageable pageable); - Page findByBookTitleContainingOrBookAuthorContainingOrBookPublisherContaining(String bookTitle, String bookAuthor, String bookPublisher, Pageable pageable); + Page findByBookNameContainingOrBookAuthorContainingOrBookPublisherContaining(String bookTitle, String bookAuthor, String bookPublisher, Pageable pageable); } \ No newline at end of file diff --git a/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java b/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java index ebdc2a5..aadd09a 100644 --- a/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java +++ b/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java @@ -10,6 +10,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.Optional; +import java.util.Random; @Service @Slf4j @@ -47,11 +48,12 @@ public ApiResponse searchAndSaveBooks(String query, int st @Transactional protected void saveBooks(NaverBookResponseDTO response) { + Random random = new Random(); for (BookDTO item : response.getItems()) { try { Book book = convertToBook(item); - + book.setBookQuantity(30 + random.nextInt(21)); if (!isDuplicateBook(book)) { bookRepository.save(book); } @@ -65,12 +67,12 @@ protected void saveBooks(NaverBookResponseDTO response) { private Book convertToBook(BookDTO item) { return Book.builder() - .bookTitle(item.getTitle()) + .bookName(item.getTitle()) .bookAuthor(item.getAuthor()) .bookLink(item.getLink()) - .bookImage(item.getImage()) + .bookImagePath(item.getImage()) .bookPublisher(item.getPublisher()) - .bookIsbn(item.getIsbn()) + .bookIsbn(String.valueOf(item.getIsbn())) .bookDescription(item.getDescription()) .bookPubdate(item.getPubdate()) .bookDiscount(item.getDiscount()) @@ -79,7 +81,7 @@ private Book convertToBook(BookDTO item) { private boolean isDuplicateBook(Book book) { - if (book.getBookIsbn() != null && book.getBookIsbn() != 0) { + if (book.getBookIsbn() != null && book.getBookIsbn().equals("0")) { Optional existingBook = bookRepository.findByBookIsbn(book.getBookIsbn()); return existingBook.isPresent(); } diff --git a/src/main/java/com/fastcampus/book_bot/service/book/BookSearchService.java b/src/main/java/com/fastcampus/book_bot/service/book/BookSearchService.java index c4a7f29..d5557fb 100644 --- a/src/main/java/com/fastcampus/book_bot/service/book/BookSearchService.java +++ b/src/main/java/com/fastcampus/book_bot/service/book/BookSearchService.java @@ -26,14 +26,14 @@ public Page searchBooks(String keyword, String searchType, Pageable pageab try { switch (searchType) { case "title": - return bookRepository.findByBookTitleContaining(keyword, pageable); + return bookRepository.findByBookNameContaining(keyword, pageable); case "author": return bookRepository.findByBookAuthorContaining(keyword, pageable); case "publisher": return bookRepository.findByBookPublisherContaining(keyword, pageable); case "all": default: - return bookRepository.findByBookTitleContainingOrBookAuthorContainingOrBookPublisherContaining(keyword, keyword, keyword, pageable); + return bookRepository.findByBookNameContainingOrBookAuthorContainingOrBookPublisherContaining(keyword, keyword, keyword, pageable); } } catch (Exception e) { log.warn("도서 검색 중 오류 발생! 조건: {}, 키워드 {}", searchType, keyword); diff --git a/src/main/resources/db/migration/V1__create_initial_schema.sql b/src/main/resources/db/migration/V1__create_initial_schema.sql new file mode 100644 index 0000000..61f92f4 --- /dev/null +++ b/src/main/resources/db/migration/V1__create_initial_schema.sql @@ -0,0 +1,136 @@ +-- 사용자 등급 테이블 +CREATE TABLE `user_grade` ( + `GRADE_ID` INT NOT NULL AUTO_INCREMENT, + `GRADE_NAME` VARCHAR(30) NOT NULL DEFAULT 'BRONZE' + CHECK (`GRADE_NAME` IN ('BRONZE', 'SILVER', 'GOLD', 'PLATINUM')), + `MIN_USAGE` INT NULL, + `ORDER_COUNT` INT NULL, + `DISCOUNT` FLOAT NULL, + `MILEAGE_RATE` FLOAT NULL, + `CREATED_AT` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UPDATED_AT` TIMESTAMP(0) NULL ON UPDATE CURRENT_TIMESTAMP, + `UPDATED_BY` INT NULL, + PRIMARY KEY (`GRADE_ID`) +); + +-- 관리자 테이블 +CREATE TABLE `admin` ( + `ADMIN_ID` INT NOT NULL AUTO_INCREMENT, + `ADMIN_NAME` VARCHAR(30) NULL, + `ADMIN_IDS` VARCHAR(30) NULL, + `ADMIN_PASSWORD` VARCHAR(30) NULL, + `CREATED_AT` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`ADMIN_ID`) +); + +-- 도서 테이블 +CREATE TABLE `books` ( + `BOOK_ID` INT NOT NULL AUTO_INCREMENT, + `BOOK_NAME` VARCHAR(300) NULL, + `BOOK_PUBLISHER` VARCHAR(300) NULL, + `BOOK_AUTHOR` VARCHAR(300) NULL, + `BOOK_DESCRIPTION` VARCHAR(3000) NULL, + `BOOK_LINK` VARCHAR(255) NULL, + `BOOK_PUBDATE` DATE NULL, + `BOOK_DISCOUNT` INT NULL, + `BOOK_IMAGE_PATH` VARCHAR(100) NULL, + `BOOK_ISBN` VARCHAR(30) NULL, + `BOOK_QUANTITY` INT NULL, + `UPDATED_BY` INT NULL, + `CREATED_AT` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UPDATED_AT` TIMESTAMP(0) NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`BOOK_ID`) +); + +-- 사용자 테이블 +CREATE TABLE `user` ( + `USER_ID` INT NOT NULL AUTO_INCREMENT, + `GRADE_ID` INT NOT NULL, + `USER_EMAIL` VARCHAR(30) NULL, + `USER_PASSWORD` VARCHAR(30) NULL, + `USER_NAME` VARCHAR(30) NULL, + `USER_PHONE` VARCHAR(30) NULL, + `USER_STATUS` VARCHAR(50) NOT NULL DEFAULT 'ACTIVE' + CHECK (`USER_STATUS` IN ('ACTIVE', 'INACTIVE', 'DEACTIVE')), + `POINT` INT NULL, + `POSTCODE` INT NULL, + `DEFAULT_ADDRESS` VARCHAR(200) NULL, + `DETAIL_ADDRESS` VARCHAR(200) NULL, + `CITY` VARCHAR(50) NULL, + `PROVINCE` VARCHAR(50) NULL, + `LAST_LOGIN` TIMESTAMP(0) NULL, + `CREATED_AT` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UPDATED_AT` TIMESTAMP(0) NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`USER_ID`), + FOREIGN KEY (`GRADE_ID`) REFERENCES `user_grade` (`GRADE_ID`) +); + +-- 주문 테이블 +CREATE TABLE `orders` ( + `ORDER_ID` INT NOT NULL AUTO_INCREMENT, + `USER_ID` INT NOT NULL, + `ORDER_STATUS` VARCHAR(30) NOT NULL DEFAULT 'ORDER_READY' + CHECK (`ORDER_STATUS` IN ('ORDER_READY', 'ORDER_PROCESSING', 'SHIPPED', 'DELIVERED')), + `TOTAL_PRICE` INT NULL, + `ORDER_DAY` DATE NULL, + `ORDER_DATE` TIMESTAMP(0) NULL, + `CREATED_AT` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UPDATED_AT` TIMESTAMP(0) NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`ORDER_ID`), + FOREIGN KEY (`USER_ID`) REFERENCES `user` (`USER_ID`) +); + +-- 주문 도서 테이블 +CREATE TABLE `order_book` ( + `ORDER_BOOK_ID` INT NOT NULL AUTO_INCREMENT, + `ORDER_ID` INT NOT NULL, + `BOOK_ID` INT NOT NULL, + `QUANTITY` INT NULL, + `PRICE` INT NULL, + PRIMARY KEY (`ORDER_BOOK_ID`), + FOREIGN KEY (`ORDER_ID`) REFERENCES `orders` (`ORDER_ID`), + FOREIGN KEY (`BOOK_ID`) REFERENCES `books` (`BOOK_ID`) +); + +-- 리뷰 테이블 +CREATE TABLE `review` ( + `REVIEW_ID` INT NOT NULL AUTO_INCREMENT, + `BOOK_ID` INT NOT NULL, + `USER_ID` INT NOT NULL, + `REVIEW_CONTENT` VARCHAR(255) NULL, + `REVIEW_SCORE` FLOAT NULL, + `REVIEW_STATUS` VARCHAR(30) NULL, + `CREATED_AT` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UPDATED_AT` TIMESTAMP(0) NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`REVIEW_ID`), + FOREIGN KEY (`BOOK_ID`) REFERENCES `books` (`BOOK_ID`), + FOREIGN KEY (`USER_ID`) REFERENCES `user` (`USER_ID`) +); + +-- 장바구니 테이블 +CREATE TABLE `cart` ( + `CART_ID` INT NOT NULL AUTO_INCREMENT, + `BOOK_ID` INT NOT NULL, + `USER_ID` INT NOT NULL, + `CREATED_AT` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UPDATED_AT` TIMESTAMP(0) NULL ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`CART_ID`), + FOREIGN KEY (`BOOK_ID`) REFERENCES `books` (`BOOK_ID`), + FOREIGN KEY (`USER_ID`) REFERENCES `user` (`USER_ID`) +); + +-- 관리 로그 테이블 +CREATE TABLE `manage_log` ( + `LOG_ID` INT NOT NULL AUTO_INCREMENT, + `ADMIN_ID` INT NOT NULL, + `BOOK_ID` INT NOT NULL, + `USER_ID` INT NOT NULL, + `ACTION_TYPE` VARCHAR(30) NULL, + `BEFORE_DATA` JSON NULL, + `AFTER_DATA` JSON NULL, + `CREATED_AT` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`LOG_ID`), + FOREIGN KEY (`ADMIN_ID`) REFERENCES `admin` (`ADMIN_ID`), + FOREIGN KEY (`BOOK_ID`) REFERENCES `books` (`BOOK_ID`), + FOREIGN KEY (`USER_ID`) REFERENCES `user` (`USER_ID`) +); \ No newline at end of file From 93d40790faae10044eab865d9bdcc30e1a3dd062 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Mon, 11 Aug 2025 10:30:28 +0900 Subject: [PATCH 12/27] =?UTF-8?q?update=20:=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../book_bot/service/api/ApiToMySQLService.java | 11 ++++++----- src/main/resources/templates/book/search.html | 6 +++--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java b/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java index aadd09a..a5dc1a7 100644 --- a/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java +++ b/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java @@ -23,7 +23,6 @@ public class ApiToMySQLService { * - 트랜잭션을 사용하여 데이터 일관성 유지 * */ - private final BookRepository bookRepository; private final NaverBookAPIService naverBookAPIService; @@ -80,11 +79,13 @@ private Book convertToBook(BookDTO item) { } private boolean isDuplicateBook(Book book) { + String isbn = book.getBookIsbn(); - if (book.getBookIsbn() != null && book.getBookIsbn().equals("0")) { - Optional existingBook = bookRepository.findByBookIsbn(book.getBookIsbn()); - return existingBook.isPresent(); + if (isbn == null || isbn.trim().isEmpty() || isbn.equals("0")) { + return false; } - return false; + + Optional existingBook = bookRepository.findByBookIsbn(isbn); + return existingBook.isPresent(); } } diff --git a/src/main/resources/templates/book/search.html b/src/main/resources/templates/book/search.html index 1c52af2..b5c2db6 100644 --- a/src/main/resources/templates/book/search.html +++ b/src/main/resources/templates/book/search.html @@ -234,12 +234,12 @@

-
-
책 제목
+
책 제목

저자: 작가명

출판사: 출판사명

From 98278faf52adc63380827b82010121da6bc38e69 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Wed, 13 Aug 2025 18:00:37 +0900 Subject: [PATCH 13/27] =?UTF-8?q?update=20:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EB=B0=8F=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 + .../common/advice/GlobalExceptionHandler.java | 50 + .../common/config/SecurityConfig.java | 15 + .../{ => common}/config/WebClientConfig.java | 2 +- .../book_bot/common/response/ApiResponse.java | 8 + .../book_bot/common/utils/EncoderUtils.java | 22 + .../controller/auth/AuthController.java | 78 ++ .../controller/auth/AuthViewController.java | 18 + .../controller/book/BookController.java | 2 +- .../fastcampus/book_bot/domain/book/Book.java | 2 +- .../fastcampus/book_bot/domain/user/User.java | 22 +- .../book_bot/domain/user/UserGrade.java | 6 +- .../book_bot/dto/user/SignupRequestDTO.java | 28 + .../book_bot/repository/BookRepository.java | 2 +- .../repository/UserGradeRepository.java | 7 + .../book_bot/repository/UserRepository.java | 14 + .../book_bot/service/auth/AuthService.java | 110 +++ .../book_bot/service/auth/MailService.java | 80 ++ .../service/book/BookSearchService.java | 2 +- .../db/migration/V2__Alter_column_user.sql | 2 + .../db/migration/V3__Alter_column_user.sql | 2 + .../migration/V4__Insert_table_usergrade.sql | 6 + .../db/migration/V5__Insert_table_user.sql | 3 + src/main/resources/templates/auth/email.html | 58 ++ src/main/resources/templates/auth/login.html | 485 ++++++++++ src/main/resources/templates/auth/signup.html | 897 ++++++++++++++++++ .../templates/common/navigation.html | 2 +- 27 files changed, 1909 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/fastcampus/book_bot/common/advice/GlobalExceptionHandler.java create mode 100644 src/main/java/com/fastcampus/book_bot/common/config/SecurityConfig.java rename src/main/java/com/fastcampus/book_bot/{ => common}/config/WebClientConfig.java (97%) create mode 100644 src/main/java/com/fastcampus/book_bot/common/utils/EncoderUtils.java create mode 100644 src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java create mode 100644 src/main/java/com/fastcampus/book_bot/controller/auth/AuthViewController.java create mode 100644 src/main/java/com/fastcampus/book_bot/dto/user/SignupRequestDTO.java create mode 100644 src/main/java/com/fastcampus/book_bot/repository/UserGradeRepository.java create mode 100644 src/main/java/com/fastcampus/book_bot/repository/UserRepository.java create mode 100644 src/main/java/com/fastcampus/book_bot/service/auth/AuthService.java create mode 100644 src/main/java/com/fastcampus/book_bot/service/auth/MailService.java create mode 100644 src/main/resources/db/migration/V2__Alter_column_user.sql create mode 100644 src/main/resources/db/migration/V3__Alter_column_user.sql create mode 100644 src/main/resources/db/migration/V4__Insert_table_usergrade.sql create mode 100644 src/main/resources/db/migration/V5__Insert_table_user.sql create mode 100644 src/main/resources/templates/auth/email.html create mode 100644 src/main/resources/templates/auth/login.html create mode 100644 src/main/resources/templates/auth/signup.html diff --git a/build.gradle b/build.gradle index b167e2e..2fc8f1d 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.springframework.security:spring-security-crypto:5.7.2' implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-mysql' compileOnly 'org.projectlombok:lombok' diff --git a/src/main/java/com/fastcampus/book_bot/common/advice/GlobalExceptionHandler.java b/src/main/java/com/fastcampus/book_bot/common/advice/GlobalExceptionHandler.java new file mode 100644 index 0000000..1c2f5b6 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/common/advice/GlobalExceptionHandler.java @@ -0,0 +1,50 @@ +package com.fastcampus.book_bot.common.advice; + +import com.fastcampus.book_bot.common.response.ApiResponse; +import jakarta.persistence.EntityNotFoundException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.nio.file.AccessDeniedException; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity> handleRuntimeException(RuntimeException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse.error( + e.getMessage(), "SERVER_ERROR" + )); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException e) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error( + e.getMessage(), "VALIDATION_FAILED" + )); + } + + @ExceptionHandler(EntityNotFoundException.class) + public ResponseEntity> handleNotFound(EntityNotFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.error( + e.getMessage(), "NOT_FOUND" + )); + } + + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity> handleAccessDenied(AccessDeniedException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error( + e.getMessage(), "ACCESS_DENIED" + )); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse.error( + e.getMessage(), "SERVER_ERROR" + )); + } +} diff --git a/src/main/java/com/fastcampus/book_bot/common/config/SecurityConfig.java b/src/main/java/com/fastcampus/book_bot/common/config/SecurityConfig.java new file mode 100644 index 0000000..0b18808 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/common/config/SecurityConfig.java @@ -0,0 +1,15 @@ +package com.fastcampus.book_bot.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class SecurityConfig { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/fastcampus/book_bot/config/WebClientConfig.java b/src/main/java/com/fastcampus/book_bot/common/config/WebClientConfig.java similarity index 97% rename from src/main/java/com/fastcampus/book_bot/config/WebClientConfig.java rename to src/main/java/com/fastcampus/book_bot/common/config/WebClientConfig.java index 150c41f..3932b1f 100644 --- a/src/main/java/com/fastcampus/book_bot/config/WebClientConfig.java +++ b/src/main/java/com/fastcampus/book_bot/common/config/WebClientConfig.java @@ -1,4 +1,4 @@ -package com.fastcampus.book_bot.config; +package com.fastcampus.book_bot.common.config; import io.netty.channel.ChannelOption; import io.netty.handler.timeout.ReadTimeoutHandler; diff --git a/src/main/java/com/fastcampus/book_bot/common/response/ApiResponse.java b/src/main/java/com/fastcampus/book_bot/common/response/ApiResponse.java index 3f6a6c7..a6de88d 100644 --- a/src/main/java/com/fastcampus/book_bot/common/response/ApiResponse.java +++ b/src/main/java/com/fastcampus/book_bot/common/response/ApiResponse.java @@ -17,6 +17,14 @@ public class ApiResponse { private T data; private String errorCode; + /* 성공 응답 (메시지) */ + public static ApiResponse success(String message) { + return ApiResponse.builder() + .success(true) + .message(message) + .build(); + } + /* 성공 응답 (응답 데이터) */ public static ApiResponse success(T data) { return ApiResponse.builder() diff --git a/src/main/java/com/fastcampus/book_bot/common/utils/EncoderUtils.java b/src/main/java/com/fastcampus/book_bot/common/utils/EncoderUtils.java new file mode 100644 index 0000000..1ba582d --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/common/utils/EncoderUtils.java @@ -0,0 +1,22 @@ +package com.fastcampus.book_bot.common.utils; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class EncoderUtils { + + private final PasswordEncoder passwordEncoder; + + /* 패스워드 인코더 */ + public String passwordEncode(String password) { + return passwordEncoder.encode(password); + } + + /* 패스워드 검증 */ + public boolean validatePassword(String password, String encodePassword) { + return passwordEncoder.matches(password, encodePassword); + } +} diff --git a/src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java b/src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java new file mode 100644 index 0000000..f2bdae6 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java @@ -0,0 +1,78 @@ +package com.fastcampus.book_bot.controller.auth; + +import com.fastcampus.book_bot.common.response.ApiResponse; +import com.fastcampus.book_bot.dto.user.SignupRequestDTO; +import com.fastcampus.book_bot.service.auth.AuthService; +import com.fastcampus.book_bot.service.auth.MailService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/member") +@RequiredArgsConstructor +public class AuthController { + + private final MailService mailService; + private final AuthService authService; + + /* 로그인 */ + @PostMapping("/login") + public ResponseEntity> login(@RequestBody SignupRequestDTO request) { + authService.login(request); + + return ResponseEntity.ok(ApiResponse.success("로그인 완료!")); + } + + /* 회원가입 */ + @PostMapping("/signup") + public ResponseEntity> signup(@RequestBody SignupRequestDTO request) { + authService.signup(request); + + return ResponseEntity.ok(ApiResponse.success("회원가입 완료!")); + } + + /* 닉네임 중복 */ + @GetMapping("/check-nickname") + public ResponseEntity> checkNickname(@RequestParam String nickname) { + boolean response = authService.isDuplicateNickname(nickname); + + if (response) { + return ResponseEntity.ok(ApiResponse.success("중복된 닉네임입니다.")); + } else { + return ResponseEntity.ok(ApiResponse.success("가능한!")); + } + } + + /* 이메일 중복 */ + @GetMapping("/check-email") + public ResponseEntity> checkEmail(@RequestParam String userEmail) { + boolean response = authService.isDuplicateEmail(userEmail); + + if (response) { + return ResponseEntity.ok(ApiResponse.success("중복된 이메일입니다.")); + } else { + return ResponseEntity.ok(ApiResponse.success("가능한!")); + } + } + + /* 이메일 전송 */ + @PostMapping("/send-verification") + public ResponseEntity> sendEmail(@RequestParam String userEmail) { + mailService.sendVerificationCode(userEmail); + + return ResponseEntity.ok(ApiResponse.success("이메일 전송이 완료되었습니다.")); + } + + /* 이메일 검증 */ + @PostMapping("/verify-email") + public ResponseEntity> verifyEmail(@RequestBody SignupRequestDTO signupRequest) { + boolean response = mailService.verifyCode(signupRequest.getEmail(), signupRequest.getVerificationCode()); + + if (response) { + return ResponseEntity.ok(ApiResponse.success("이메일 검증 성공")); + } else { + return ResponseEntity.ok(ApiResponse.success("인증번호가 틀립니다")); + } + } +} diff --git a/src/main/java/com/fastcampus/book_bot/controller/auth/AuthViewController.java b/src/main/java/com/fastcampus/book_bot/controller/auth/AuthViewController.java new file mode 100644 index 0000000..327cacc --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/controller/auth/AuthViewController.java @@ -0,0 +1,18 @@ +package com.fastcampus.book_bot.controller.auth; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class AuthViewController { + + @GetMapping("/login") + public String login() { + return "auth/login"; + } + + @GetMapping("/signup") + public String signup() { + return "auth/signup"; + } +} diff --git a/src/main/java/com/fastcampus/book_bot/controller/book/BookController.java b/src/main/java/com/fastcampus/book_bot/controller/book/BookController.java index e0b0b28..c24ab04 100644 --- a/src/main/java/com/fastcampus/book_bot/controller/book/BookController.java +++ b/src/main/java/com/fastcampus/book_bot/controller/book/BookController.java @@ -46,7 +46,7 @@ public String searchBooks(@ModelAttribute SearchDTO searchDTO, } @GetMapping("/book/{bookId}") - public String bookDetail(@PathVariable Long bookId, Model model) { + public String bookDetail(@PathVariable Integer bookId, Model model) { Optional book = bookSearchService.getBookById(bookId); if (book.isPresent()) { diff --git a/src/main/java/com/fastcampus/book_bot/domain/book/Book.java b/src/main/java/com/fastcampus/book_bot/domain/book/Book.java index 71103da..57c0071 100644 --- a/src/main/java/com/fastcampus/book_bot/domain/book/Book.java +++ b/src/main/java/com/fastcampus/book_bot/domain/book/Book.java @@ -14,7 +14,7 @@ import java.time.LocalDateTime; @Entity -@Table(name = "`BOOKS`") +@Table(name = "books") @EntityListeners(AuditingEntityListener.class) @Data @Builder diff --git a/src/main/java/com/fastcampus/book_bot/domain/user/User.java b/src/main/java/com/fastcampus/book_bot/domain/user/User.java index 2c37138..e214a6b 100644 --- a/src/main/java/com/fastcampus/book_bot/domain/user/User.java +++ b/src/main/java/com/fastcampus/book_bot/domain/user/User.java @@ -2,10 +2,9 @@ import jakarta.persistence.*; import jakarta.validation.constraints.Email; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; +import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @@ -13,12 +12,13 @@ import java.time.LocalDateTime; @Entity -@Table(name = "USER") +@Table(name = "user") @EntityListeners(AuditingEntityListener.class) -@Data @Builder +@Getter @AllArgsConstructor @NoArgsConstructor + public class User { @Id @@ -27,7 +27,7 @@ public class User { private Integer userId; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "GRADE_ID", nullable = false) + @JoinColumn(name = "GRADE_ID", nullable = false, insertable = false) private UserGrade userGrade; @Column(name = "USER_EMAIL", length = 30) @@ -40,13 +40,16 @@ public class User { @Column(name = "USER_NAME", length = 30) private String userName; + @Column(name = "USER_NICKNAME", length = 20) + private String userNickname; + @Column(name = "USER_PHONE", length = 30) private String userPhone; @Column(name = "USER_STATUS", length = 50, nullable = false) - private String userStatus = "ACTIVE"; + private String userStatus; - @Column(name = "POINT") + @Column(name = "POINT", insertable = false) private Integer point; @Column(name = "POSTCODE") @@ -74,4 +77,5 @@ public class User { @LastModifiedDate @Column(name = "UPDATED_AT") private LocalDateTime updatedAt; + } \ No newline at end of file diff --git a/src/main/java/com/fastcampus/book_bot/domain/user/UserGrade.java b/src/main/java/com/fastcampus/book_bot/domain/user/UserGrade.java index 26a3e54..3e7c2b8 100644 --- a/src/main/java/com/fastcampus/book_bot/domain/user/UserGrade.java +++ b/src/main/java/com/fastcampus/book_bot/domain/user/UserGrade.java @@ -12,7 +12,7 @@ import java.time.LocalDateTime; @Entity -@Table(name = "USER_GRADE") // 테이블명 수정 +@Table(name = "user_grade") @EntityListeners(AuditingEntityListener.class) @Data @Builder @@ -20,13 +20,15 @@ @NoArgsConstructor public class UserGrade { + public static UserGrade defaultGrade; + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "GRADE_ID", updatable = false, nullable = false) private Integer gradeId; @Column(name = "GRADE_NAME", nullable = false) - private String gradeName = "BRONZE"; + private String gradeName; @Column(name = "MIN_USAGE") private Integer minUsage; diff --git a/src/main/java/com/fastcampus/book_bot/dto/user/SignupRequestDTO.java b/src/main/java/com/fastcampus/book_bot/dto/user/SignupRequestDTO.java new file mode 100644 index 0000000..90e8fe1 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/dto/user/SignupRequestDTO.java @@ -0,0 +1,28 @@ +package com.fastcampus.book_bot.dto.user; + +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +@Builder +public class SignupRequestDTO { + + /* 회원가입 필드 */ + private String name; + private String nickname; + private String email; + private String password; + private String passwordConfirm; + private String phone; + private LocalDate birthDate; + private boolean agreeTerms; + private boolean emailVerified; + + /* 이메일 검증 필드 */ + private String verificationCode; + + /* 로그인 검증 필드 */ + private boolean rememberMe; +} diff --git a/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java b/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java index 4a9e3eb..e1bf658 100644 --- a/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java +++ b/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java @@ -9,7 +9,7 @@ import java.util.Optional; @Repository -public interface BookRepository extends JpaRepository { +public interface BookRepository extends JpaRepository { Optional findByBookIsbn(String bookIsbn); Page findByBookNameContaining(String bookTitle, Pageable pageable); diff --git a/src/main/java/com/fastcampus/book_bot/repository/UserGradeRepository.java b/src/main/java/com/fastcampus/book_bot/repository/UserGradeRepository.java new file mode 100644 index 0000000..c50592d --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/repository/UserGradeRepository.java @@ -0,0 +1,7 @@ +package com.fastcampus.book_bot.repository; + +import com.fastcampus.book_bot.domain.user.UserGrade; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserGradeRepository extends JpaRepository { +} diff --git a/src/main/java/com/fastcampus/book_bot/repository/UserRepository.java b/src/main/java/com/fastcampus/book_bot/repository/UserRepository.java new file mode 100644 index 0000000..d762387 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/repository/UserRepository.java @@ -0,0 +1,14 @@ +package com.fastcampus.book_bot.repository; + +import com.fastcampus.book_bot.domain.user.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface UserRepository extends JpaRepository { + + boolean existsByUserEmail(String userEmail); + boolean existsByUserNickname(String userNickname); + + User findByUserEmail(String userEmail); +} diff --git a/src/main/java/com/fastcampus/book_bot/service/auth/AuthService.java b/src/main/java/com/fastcampus/book_bot/service/auth/AuthService.java new file mode 100644 index 0000000..2f3f549 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/service/auth/AuthService.java @@ -0,0 +1,110 @@ +package com.fastcampus.book_bot.service.auth; + +import com.fastcampus.book_bot.common.utils.EncoderUtils; +import com.fastcampus.book_bot.domain.user.User; +import com.fastcampus.book_bot.domain.user.UserGrade; +import com.fastcampus.book_bot.dto.user.SignupRequestDTO; +import com.fastcampus.book_bot.repository.UserGradeRepository; +import com.fastcampus.book_bot.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Service +@Slf4j +@RequiredArgsConstructor +public class AuthService { + + private final UserRepository userRepository; + private final UserGradeRepository userGradeRepository; + private final EncoderUtils encoderUtils; + + /* 닉네임 중복 검증 */ + @Transactional(readOnly = true) + public boolean isDuplicateNickname(String nickname) { + try { + return userRepository.existsByUserNickname(nickname); + + } catch (Exception e) { + log.error("닉네임 중복 검증 실패", e); + throw new RuntimeException("닉네임 중복 확인 중 오류 발생", e); + } + } + + /* 이메일 중복 검증 */ + @Transactional(readOnly = true) + public boolean isDuplicateEmail(String email) { + try { + return userRepository.existsByUserEmail(email); + + } catch (Exception e) { + log.error("이메일 중복 검증 실패"); + throw new RuntimeException("이메일 중복 확인 중 오류 발생", e); + } + } + + /* 회원 가입 */ + @Transactional + public void signup(SignupRequestDTO request) { + + try { + if (isDuplicateNickname(request.getNickname())) { + throw new RuntimeException("이미 사용중인 이메일입니다."); + } + + if (isDuplicateNickname(request.getNickname())) { + throw new RuntimeException("이미 사용 중인 닉네임입니다."); + } + + if (!request.isAgreeTerms()) { + throw new RuntimeException("약관에 동의해야 합니다."); + } + + if (!request.isEmailVerified()) { + throw new RuntimeException("이메일 인증이 완료되지 않았습니다."); + } + + if (!request.getPassword().equals(request.getPasswordConfirm())) { + throw new RuntimeException("비밀번호가 일치하지 않습니다."); + } + + User user = new User(); + user = User.builder() + .userEmail(request.getEmail()) + .userPassword(encoderUtils.passwordEncode(request.getPassword())) + .userName(request.getName()) + .userNickname(request.getNickname()) + .userPhone(request.getPhone()) + .userStatus("ACTIVE") + .build(); + + userRepository.save(user); + + } catch (Exception e) { + throw new RuntimeException("회원가입 중 오류 발생!", e); + } + } + + @Transactional(readOnly = true) + public void login(SignupRequestDTO request) { + + try { + User user = userRepository.findByUserEmail(request.getEmail()); + + if (user == null) { + throw new RuntimeException("이메일 또는 비밀번호를 확인해주세요"); + } + + if (!encoderUtils.validatePassword(request.getPassword(), user.getUserPassword())) { + throw new RuntimeException("이메일 또는 비밀번호를 확인해주세요"); + } + + } catch (Exception e) { + throw new RuntimeException("로그인 중 오류 발생!", e); + } + } + +} diff --git a/src/main/java/com/fastcampus/book_bot/service/auth/MailService.java b/src/main/java/com/fastcampus/book_bot/service/auth/MailService.java new file mode 100644 index 0000000..d1aafe0 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/service/auth/MailService.java @@ -0,0 +1,80 @@ +package com.fastcampus.book_bot.service.auth; + +import com.fastcampus.book_bot.common.response.ApiResponse; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +import java.time.Duration; +import java.util.Random; + +@Service +@Slf4j +@RequiredArgsConstructor +public class MailService { + + private final TemplateEngine templateEngine; + private final JavaMailSender javaMailSender; + private final RedisTemplate redisTemplaRte; + + private static final String EMAIL_VERIFICATION_PREFIX = "email_verification:"; + private static final int VERIFICATION_EXPIRE_MINUTES = 10; + private final RedisTemplate redisTemplate; + + /* 이메일 보내기 */ + private Integer sendMailMessage(String userEmail) { + MimeMessage message = javaMailSender.createMimeMessage(); + + try { + MimeMessageHelper messageHelper = new MimeMessageHelper(message, true, "UTF-8"); + messageHelper.setTo(userEmail); + messageHelper.setSubject("온라인 서점 회원가입 인증번호 안내"); + + Context context = new Context(); + Random random = new Random(); + Integer code = random.nextInt(100000, 1000000); + context.setVariable("verificationCode", code); + messageHelper.setText(templateEngine.process("auth/email", context), true); + + javaMailSender.send(message); + return code; + + } catch (Exception e) { + log.error("메일 전송 실패", e); + throw new RuntimeException("메일 전송 실패"); + } + + } + + /* redis에 이메일, 인증 번호 저장 */ + public void sendVerificationCode(String userEmail) { + + Integer code = sendMailMessage(userEmail); + String key = EMAIL_VERIFICATION_PREFIX + userEmail; + redisTemplaRte.opsForValue().set(key, code.toString(), Duration.ofMinutes(VERIFICATION_EXPIRE_MINUTES)); + } + + /* redis에서 인증 번호 검증 */ + public boolean verifyCode(String userEmail, String inputCode) { + try { + String key = EMAIL_VERIFICATION_PREFIX + userEmail; + String storedCode = redisTemplaRte.opsForValue().get(key); + + if (storedCode != null && storedCode.equals(inputCode)) { + redisTemplate.delete(key); + return true; + } else { + return false; + } + } catch (Exception e) { + log.error("인증 코드 검증 실패: {}", userEmail, e); + throw new RuntimeException("인증 코드 검증 실패"); + } + } +} diff --git a/src/main/java/com/fastcampus/book_bot/service/book/BookSearchService.java b/src/main/java/com/fastcampus/book_bot/service/book/BookSearchService.java index d5557fb..c29cb28 100644 --- a/src/main/java/com/fastcampus/book_bot/service/book/BookSearchService.java +++ b/src/main/java/com/fastcampus/book_bot/service/book/BookSearchService.java @@ -42,7 +42,7 @@ public Page searchBooks(String keyword, String searchType, Pageable pageab } @Transactional(readOnly = true) - public Optional getBookById(Long bookId) { + public Optional getBookById(Integer bookId) { log.info("도서 상세 조회 - bookId: {}", bookId); try { diff --git a/src/main/resources/db/migration/V2__Alter_column_user.sql b/src/main/resources/db/migration/V2__Alter_column_user.sql new file mode 100644 index 0000000..7318cee --- /dev/null +++ b/src/main/resources/db/migration/V2__Alter_column_user.sql @@ -0,0 +1,2 @@ +ALTER TABLE user + ADD COLUMN USER_NICKNAME VARCHAR(20) NOT NULL; \ No newline at end of file diff --git a/src/main/resources/db/migration/V3__Alter_column_user.sql b/src/main/resources/db/migration/V3__Alter_column_user.sql new file mode 100644 index 0000000..eb74243 --- /dev/null +++ b/src/main/resources/db/migration/V3__Alter_column_user.sql @@ -0,0 +1,2 @@ +ALTER TABLE `user` + MODIFY COLUMN USER_PASSWORD VARCHAR(80) NOT NULL; \ No newline at end of file diff --git a/src/main/resources/db/migration/V4__Insert_table_usergrade.sql b/src/main/resources/db/migration/V4__Insert_table_usergrade.sql new file mode 100644 index 0000000..fefe807 --- /dev/null +++ b/src/main/resources/db/migration/V4__Insert_table_usergrade.sql @@ -0,0 +1,6 @@ +INSERT INTO `user_grade` (`GRADE_NAME`, `MIN_USAGE`, `ORDER_COUNT`, `DISCOUNT`, `MILEAGE_RATE`) +VALUES + ('BRONZE', 100, 1, 0, 0), + ('SILVER', 100000, 3, 0.03, 0.03), + ('GOLD', 300000, 5, 0.05, 0.05), + ('PLATINUM', 500000, 10, 0.08, 0.08); \ No newline at end of file diff --git a/src/main/resources/db/migration/V5__Insert_table_user.sql b/src/main/resources/db/migration/V5__Insert_table_user.sql new file mode 100644 index 0000000..b08e32e --- /dev/null +++ b/src/main/resources/db/migration/V5__Insert_table_user.sql @@ -0,0 +1,3 @@ +ALTER TABLE `user` + MODIFY COLUMN `GRADE_ID` INT NOT NULL DEFAULT 1, + MODIFY COLUMN `POINT` INT NULL DEFAULT 0; \ No newline at end of file diff --git a/src/main/resources/templates/auth/email.html b/src/main/resources/templates/auth/email.html new file mode 100644 index 0000000..29a0f4e --- /dev/null +++ b/src/main/resources/templates/auth/email.html @@ -0,0 +1,58 @@ + + + + + + 온라인 서점 이메일 인증 + + +
+ + +
+

📚 온라인 서점

+

이메일 인증번호 안내

+
+ + +
+

회원가입 인증번호

+

+ 안녕하세요! 온라인 서점 회원가입을 위한 이메일 인증번호입니다.
+ 아래 인증번호를 회원가입 페이지에 입력해주세요. +

+ + +
+

인증번호

+
+ +
+
+ + +
+

+ ⏰ 유효시간: 5분
+ 본 인증번호는 5분 후 자동으로 만료됩니다. +

+
+ +

+ • 인증번호가 만료된 경우 다시 요청해주세요
+ • 본인이 요청하지 않았다면 이 메일을 무시해주세요
+ • 문의사항이 있으시면 고객센터로 연락해주세요 +

+
+ + +
+

+ 본 메일은 발신전용입니다. 문의사항은 고객센터를 이용해주세요.
+ © 2025 온라인 서점. All rights reserved. +

+
+ +
+ + \ No newline at end of file diff --git a/src/main/resources/templates/auth/login.html b/src/main/resources/templates/auth/login.html new file mode 100644 index 0000000..b0f787f --- /dev/null +++ b/src/main/resources/templates/auth/login.html @@ -0,0 +1,485 @@ + + + + + + 로그인 - 온라인 서점 + + + + + + +
+ + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/auth/signup.html b/src/main/resources/templates/auth/signup.html new file mode 100644 index 0000000..1e5b15d --- /dev/null +++ b/src/main/resources/templates/auth/signup.html @@ -0,0 +1,897 @@ + + + + + + 회원가입 - 온라인 서점 + + + + + + +
+ + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/common/navigation.html b/src/main/resources/templates/common/navigation.html index ba12dbd..c96c44f 100644 --- a/src/main/resources/templates/common/navigation.html +++ b/src/main/resources/templates/common/navigation.html @@ -13,7 +13,7 @@
From ed796724bed2803821ce4d138d4cf4ecd6ee0982 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Wed, 13 Aug 2025 22:14:03 +0900 Subject: [PATCH 14/27] =?UTF-8?q?update=20:=20Api=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=B6=94=EC=83=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/response/BaseApiResponse.java | 18 +++++++++ .../common/response/ErrorApiResponse.java | 40 +++++++++++++++++++ .../common/response/SuccessApiResponse.java | 37 +++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 src/main/java/com/fastcampus/book_bot/common/response/BaseApiResponse.java create mode 100644 src/main/java/com/fastcampus/book_bot/common/response/ErrorApiResponse.java create mode 100644 src/main/java/com/fastcampus/book_bot/common/response/SuccessApiResponse.java diff --git a/src/main/java/com/fastcampus/book_bot/common/response/BaseApiResponse.java b/src/main/java/com/fastcampus/book_bot/common/response/BaseApiResponse.java new file mode 100644 index 0000000..2c66c24 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/common/response/BaseApiResponse.java @@ -0,0 +1,18 @@ +package com.fastcampus.book_bot.common.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@AllArgsConstructor(access = AccessLevel.PROTECTED) +@JsonInclude(JsonInclude.Include.NON_NULL) +public abstract class BaseApiResponse { + + protected final boolean success; + protected final String message; + protected final LocalDateTime timestamp; +} diff --git a/src/main/java/com/fastcampus/book_bot/common/response/ErrorApiResponse.java b/src/main/java/com/fastcampus/book_bot/common/response/ErrorApiResponse.java new file mode 100644 index 0000000..78aa4f6 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/common/response/ErrorApiResponse.java @@ -0,0 +1,40 @@ +package com.fastcampus.book_bot.common.response; + +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * 에러 API 응답을 나타내는 클래스 + * + * @param 상세한 에러 정보 타입 + */ +@Getter +public class ErrorApiResponse extends BaseApiResponse { + + private final String errorCode; + private final T errorDetail; + + public ErrorApiResponse(String message, String errorCode, T errorDetail) { + super(false, message, LocalDateTime.now()); + this.errorCode = errorCode; + this.errorDetail = errorDetail; + } + + public static ErrorApiResponse message(String message) { + return new ErrorApiResponse<>(message, null, null); + } + + public static ErrorApiResponse of(String message, String errorCode) { + return new ErrorApiResponse<>(message, errorCode, null); + } + + /** + * @param message 응답 메시지 + * @param errorCode 에러 코드 + * @param errorDetail 상세한 에러 정보 + */ + public static ErrorApiResponse of(String message, String errorCode, T errorDetail) { + return new ErrorApiResponse<>(message, errorCode, errorDetail); + } +} diff --git a/src/main/java/com/fastcampus/book_bot/common/response/SuccessApiResponse.java b/src/main/java/com/fastcampus/book_bot/common/response/SuccessApiResponse.java new file mode 100644 index 0000000..d21bec0 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/common/response/SuccessApiResponse.java @@ -0,0 +1,37 @@ +package com.fastcampus.book_bot.common.response; + +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * 성공적인 API 응답을 나타내는 클래스 + * + * @param 응답 데이터의 타입 + */ +@Getter +public class SuccessApiResponse extends BaseApiResponse { + + private final T data; + + public SuccessApiResponse(String message, T data) { + super(true, message, LocalDateTime.now()); + this.data = data; + } + + public static SuccessApiResponse of(T data) { + return new SuccessApiResponse<>(null, data); + } + + /** + * @param data 응답 데이터 + * @param message 응답 메시지 + */ + public static SuccessApiResponse of(T data, String message) { + return new SuccessApiResponse<>(message, data); + } + + public static SuccessApiResponse message(String message) { + return new SuccessApiResponse<>(message, null); + } +} \ No newline at end of file From d6fa8428765b7736fcfb6e1c80d2e8a7c6920e19 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Wed, 13 Aug 2025 22:15:27 +0900 Subject: [PATCH 15/27] =?UTF-8?q?chore=20:=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fastcampus/book_bot/common/response/BaseApiResponse.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/fastcampus/book_bot/common/response/BaseApiResponse.java b/src/main/java/com/fastcampus/book_bot/common/response/BaseApiResponse.java index 2c66c24..fb901fc 100644 --- a/src/main/java/com/fastcampus/book_bot/common/response/BaseApiResponse.java +++ b/src/main/java/com/fastcampus/book_bot/common/response/BaseApiResponse.java @@ -7,6 +7,10 @@ import java.time.LocalDateTime; +/** + * API 응답의 기본 구조를 제공하는 추상 클래스 + * 성공 여부, 메시지, 응답 시각 포함 + */ @Getter @AllArgsConstructor(access = AccessLevel.PROTECTED) @JsonInclude(JsonInclude.Include.NON_NULL) From d04470534af10a14d49ed94bc7149fc0a020f346 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Fri, 15 Aug 2025 14:57:26 +0900 Subject: [PATCH 16/27] =?UTF-8?q?chore=20:=20RestFul=20API=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20Exception=20=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/exception/BaseDomainException.java | 19 +++++++ .../exception/user/UserDomainException.java | 51 +++++++++++++++++++ .../book_bot/common/response/ApiResponse.java | 10 ++-- .../fastcampus/book_bot/domain/user/User.java | 1 - .../book_bot/dto/user/SignupRequestDTO.java | 18 +++++++ .../book_bot/service/auth/AuthService.java | 22 +++----- 6 files changed, 101 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/fastcampus/book_bot/common/exception/BaseDomainException.java create mode 100644 src/main/java/com/fastcampus/book_bot/common/exception/user/UserDomainException.java diff --git a/src/main/java/com/fastcampus/book_bot/common/exception/BaseDomainException.java b/src/main/java/com/fastcampus/book_bot/common/exception/BaseDomainException.java new file mode 100644 index 0000000..cfb65b2 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/common/exception/BaseDomainException.java @@ -0,0 +1,19 @@ +package com.fastcampus.book_bot.common.exception; + +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +public abstract class BaseDomainException extends RuntimeException { + protected final String errorCode; + protected final String domain; + protected final HttpStatus httpStatus; + + protected BaseDomainException(String message, String errorCode, + String domain, HttpStatus httpStatus) { + super(message); + this.errorCode = errorCode; + this.domain = domain; + this.httpStatus = httpStatus; + } +} diff --git a/src/main/java/com/fastcampus/book_bot/common/exception/user/UserDomainException.java b/src/main/java/com/fastcampus/book_bot/common/exception/user/UserDomainException.java new file mode 100644 index 0000000..ff3f1ce --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/common/exception/user/UserDomainException.java @@ -0,0 +1,51 @@ +package com.fastcampus.book_bot.common.exception.user; + +import com.fastcampus.book_bot.common.exception.BaseDomainException; +import org.springframework.http.HttpStatus; + +public class UserDomainException extends BaseDomainException { + + public UserDomainException(String message, String errorCode, HttpStatus httpStatus) { + super(message, errorCode, "USER", httpStatus); + } + + public static UserDomainException notFound(Integer userId) { + return new UserDomainException( + "사용자를 찾을 수 없습니다. USER_ID: {}" + userId, + "USER_NOT_FOUND", + HttpStatus.NOT_FOUND + ); + } + + public static UserDomainException emailDuplicate(String email) { + return new UserDomainException( + "이미 존재하는 이메일입니다: " + email, + "EMAIL_ALREADY_EXISTS", + HttpStatus.CONFLICT + ); + } + + public static UserDomainException nicknameDuplicate(String nickname) { + return new UserDomainException( + "이미 존재하는 닉네임입니다: " + nickname, + "NICKNAME_ALREADY_EXISTS", + HttpStatus.CONFLICT + ); + } + + public static UserDomainException unauthorized() { + return new UserDomainException( + "접근 권한이 없습니다", + "UNAUTHORIZED_ACCESS", + HttpStatus.FORBIDDEN + ); + } + + public static UserDomainException invalidData(String field, String reason) { + return new UserDomainException( + String.format("유효하지 않은 데이터 - %s: %s", field, reason), + "USER_INVALID_DATA", + HttpStatus.BAD_REQUEST + ); + } +} diff --git a/src/main/java/com/fastcampus/book_bot/common/response/ApiResponse.java b/src/main/java/com/fastcampus/book_bot/common/response/ApiResponse.java index a6de88d..78d1dfd 100644 --- a/src/main/java/com/fastcampus/book_bot/common/response/ApiResponse.java +++ b/src/main/java/com/fastcampus/book_bot/common/response/ApiResponse.java @@ -3,15 +3,15 @@ import lombok.Builder; import lombok.Getter; +/** ApiResponse 클래스는 API 응답의 기본 구조를 정의 + * 성공 여부, 메시지, 데이터, 에러 코드 생성 + * 성공 응답과 에러 응답을 생성하는 정적 메서드를 제공 + * @param 응답 데이터 + * */ @Getter @Builder public class ApiResponse { - /* ApiResponse 클래스는 API 응답의 기본 구조를 정의한다. - * 성공 여부, 메시지, 데이터, 에러 코드 생성 - * 성공 응답과 에러 응답을 생성하는 정적 메서드를 제공한다. - * */ - private Boolean success; private String message; private T data; diff --git a/src/main/java/com/fastcampus/book_bot/domain/user/User.java b/src/main/java/com/fastcampus/book_bot/domain/user/User.java index e214a6b..7d7277a 100644 --- a/src/main/java/com/fastcampus/book_bot/domain/user/User.java +++ b/src/main/java/com/fastcampus/book_bot/domain/user/User.java @@ -31,7 +31,6 @@ public class User { private UserGrade userGrade; @Column(name = "USER_EMAIL", length = 30) - @Email(message = "유효한 이메일 형식이어야 합니다.") private String userEmail; @Column(name = "USER_PASSWORD", length = 30) diff --git a/src/main/java/com/fastcampus/book_bot/dto/user/SignupRequestDTO.java b/src/main/java/com/fastcampus/book_bot/dto/user/SignupRequestDTO.java index 90e8fe1..d19a89c 100644 --- a/src/main/java/com/fastcampus/book_bot/dto/user/SignupRequestDTO.java +++ b/src/main/java/com/fastcampus/book_bot/dto/user/SignupRequestDTO.java @@ -1,5 +1,6 @@ package com.fastcampus.book_bot.dto.user; +import jakarta.validation.constraints.*; import lombok.Builder; import lombok.Getter; @@ -10,14 +11,31 @@ public class SignupRequestDTO { /* 회원가입 필드 */ + @NotBlank(message = "이름은 필수입니다.") private String name; + + @NotBlank(message = "닉네임은 필수입니다.") private String nickname; + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "올바른 이메일 형식이 아닙니다.") private String email; + + @NotBlank(message = "비밀번호는 필수입니다.") private String password; + + @NotBlank(message = "비밀번호는 필수입니다") private String passwordConfirm; + + @NotBlank(message = "전화번호는 필수입니다") private String phone; + + @NotNull(message = "생년월일은 필수입니다") + @Past(message = "생년월일은 과거 날짜여야 합니다") private LocalDate birthDate; + private boolean agreeTerms; + private boolean emailVerified; /* 이메일 검증 필드 */ diff --git a/src/main/java/com/fastcampus/book_bot/service/auth/AuthService.java b/src/main/java/com/fastcampus/book_bot/service/auth/AuthService.java index 2f3f549..1caf841 100644 --- a/src/main/java/com/fastcampus/book_bot/service/auth/AuthService.java +++ b/src/main/java/com/fastcampus/book_bot/service/auth/AuthService.java @@ -2,24 +2,19 @@ import com.fastcampus.book_bot.common.utils.EncoderUtils; import com.fastcampus.book_bot.domain.user.User; -import com.fastcampus.book_bot.domain.user.UserGrade; import com.fastcampus.book_bot.dto.user.SignupRequestDTO; -import com.fastcampus.book_bot.repository.UserGradeRepository; import com.fastcampus.book_bot.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Optional; - @Service @Slf4j @RequiredArgsConstructor public class AuthService { private final UserRepository userRepository; - private final UserGradeRepository userGradeRepository; private final EncoderUtils encoderUtils; /* 닉네임 중복 검증 */ @@ -71,15 +66,14 @@ public void signup(SignupRequestDTO request) { throw new RuntimeException("비밀번호가 일치하지 않습니다."); } - User user = new User(); - user = User.builder() - .userEmail(request.getEmail()) - .userPassword(encoderUtils.passwordEncode(request.getPassword())) - .userName(request.getName()) - .userNickname(request.getNickname()) - .userPhone(request.getPhone()) - .userStatus("ACTIVE") - .build(); + User user = User.builder() + .userEmail(request.getEmail()) + .userPassword(encoderUtils.passwordEncode(request.getPassword())) + .userName(request.getName()) + .userNickname(request.getNickname()) + .userPhone(request.getPhone()) + .userStatus("ACTIVE") + .build(); userRepository.save(user); From bf33360414c30ab92cdeb9c333e39514f9b3eda9 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Fri, 15 Aug 2025 19:43:54 +0900 Subject: [PATCH 17/27] =?UTF-8?q?chore=20:=20RestFul=20API=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20Exception=20=EC=84=A4=EA=B3=84=20V2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/advice/GlobalExceptionHandler.java | 67 ++++---- .../common/exception/BaseDomainException.java | 14 ++ .../exception/user/UserDomainException.java | 108 +++++++++---- .../common/exception/user/UserErrorCode.java | 53 +++++++ .../common/response/ErrorApiResponse.java | 25 ++- .../common/response/SuccessApiResponse.java | 15 +- .../controller/auth/AuthController.java | 45 +++--- .../book_bot/service/auth/AuthService.java | 145 +++++++++++++----- .../book_bot/service/auth/MailService.java | 79 ++++++++-- 9 files changed, 385 insertions(+), 166 deletions(-) create mode 100644 src/main/java/com/fastcampus/book_bot/common/exception/user/UserErrorCode.java diff --git a/src/main/java/com/fastcampus/book_bot/common/advice/GlobalExceptionHandler.java b/src/main/java/com/fastcampus/book_bot/common/advice/GlobalExceptionHandler.java index 1c2f5b6..bf826d4 100644 --- a/src/main/java/com/fastcampus/book_bot/common/advice/GlobalExceptionHandler.java +++ b/src/main/java/com/fastcampus/book_bot/common/advice/GlobalExceptionHandler.java @@ -1,50 +1,39 @@ package com.fastcampus.book_bot.common.advice; -import com.fastcampus.book_bot.common.response.ApiResponse; -import jakarta.persistence.EntityNotFoundException; -import org.springframework.http.HttpStatus; +import com.fastcampus.book_bot.common.exception.BaseDomainException; +import com.fastcampus.book_bot.common.response.ErrorApiResponse; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -import java.nio.file.AccessDeniedException; - @RestControllerAdvice +@Slf4j public class GlobalExceptionHandler { - @ExceptionHandler(RuntimeException.class) - public ResponseEntity> handleRuntimeException(RuntimeException e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse.error( - e.getMessage(), "SERVER_ERROR" - )); - } - - @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity> handleValidation(MethodArgumentNotValidException e) { - return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ApiResponse.error( - e.getMessage(), "VALIDATION_FAILED" - )); - } - - @ExceptionHandler(EntityNotFoundException.class) - public ResponseEntity> handleNotFound(EntityNotFoundException e) { - return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ApiResponse.error( - e.getMessage(), "NOT_FOUND" - )); - } - - @ExceptionHandler(AccessDeniedException.class) - public ResponseEntity> handleAccessDenied(AccessDeniedException e) { - return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.error( - e.getMessage(), "ACCESS_DENIED" - )); - } - - @ExceptionHandler(Exception.class) - public ResponseEntity> handleException(Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(ApiResponse.error( - e.getMessage(), "SERVER_ERROR" - )); + /** 도메인 에러 핸들러 + * @param ex 도메인 에러 + * */ + @ExceptionHandler(BaseDomainException.class) + public ResponseEntity handleDomainException(BaseDomainException ex) { + log.warn("DOMAIN EXCEPTION OCCURRED: [{}] {} - {}", + ex.getDomain(), ex.getErrorCode(), ex.getMessage()); + + ErrorApiResponse response; + + if (ex.getErrorDetail() != null) { + response = ErrorApiResponse.of( + ex.getMessage(), + ex.getErrorCode(), + ex.getErrorDetail() + ); + } else { + response = ErrorApiResponse.of( + ex.getMessage(), + ex.getErrorCode() + ); + } + + return ResponseEntity.status(ex.getHttpStatus()).body(response); } } diff --git a/src/main/java/com/fastcampus/book_bot/common/exception/BaseDomainException.java b/src/main/java/com/fastcampus/book_bot/common/exception/BaseDomainException.java index cfb65b2..ebf13a7 100644 --- a/src/main/java/com/fastcampus/book_bot/common/exception/BaseDomainException.java +++ b/src/main/java/com/fastcampus/book_bot/common/exception/BaseDomainException.java @@ -3,11 +3,14 @@ import lombok.Getter; import org.springframework.http.HttpStatus; +import java.util.Map; + @Getter public abstract class BaseDomainException extends RuntimeException { protected final String errorCode; protected final String domain; protected final HttpStatus httpStatus; + protected final Map errorDetail; protected BaseDomainException(String message, String errorCode, String domain, HttpStatus httpStatus) { @@ -15,5 +18,16 @@ protected BaseDomainException(String message, String errorCode, this.errorCode = errorCode; this.domain = domain; this.httpStatus = httpStatus; + this.errorDetail = null; + } + + protected BaseDomainException(String message, String errorCode, + String domain, HttpStatus httpStatus, + Map errorDetail) { + super(message); + this.errorCode = errorCode; + this.domain = domain; + this.httpStatus = httpStatus; + this.errorDetail = errorDetail; } } diff --git a/src/main/java/com/fastcampus/book_bot/common/exception/user/UserDomainException.java b/src/main/java/com/fastcampus/book_bot/common/exception/user/UserDomainException.java index ff3f1ce..4ca510c 100644 --- a/src/main/java/com/fastcampus/book_bot/common/exception/user/UserDomainException.java +++ b/src/main/java/com/fastcampus/book_bot/common/exception/user/UserDomainException.java @@ -3,49 +3,95 @@ import com.fastcampus.book_bot.common.exception.BaseDomainException; import org.springframework.http.HttpStatus; +import java.util.Map; + public class UserDomainException extends BaseDomainException { public UserDomainException(String message, String errorCode, HttpStatus httpStatus) { super(message, errorCode, "USER", httpStatus); } - public static UserDomainException notFound(Integer userId) { - return new UserDomainException( - "사용자를 찾을 수 없습니다. USER_ID: {}" + userId, - "USER_NOT_FOUND", - HttpStatus.NOT_FOUND - ); + public UserDomainException(String message, String errorCode, + HttpStatus httpStatus, Map errorDetails) { + super(message, errorCode, "USER", httpStatus, errorDetails); + } + + // ============== HttpStatus 기반 범용 팩토리 메서드 ============== + + /** + * BAD_REQUEST (400) 예외 생성 + */ + public static UserDomainException badRequest(String message, String errorCode) { + return new UserDomainException(message, errorCode, HttpStatus.BAD_REQUEST); + } + + public static UserDomainException badRequest(String message, String errorCode, Map errorDetails) { + return new UserDomainException(message, errorCode, HttpStatus.BAD_REQUEST, errorDetails); + } + + /** + * UNAUTHORIZED (401) 예외 생성 + */ + public static UserDomainException unauthorized(String message, String errorCode) { + return new UserDomainException(message, errorCode, HttpStatus.UNAUTHORIZED); + } + + public static UserDomainException unauthorized(String message, String errorCode, Map errorDetails) { + return new UserDomainException(message, errorCode, HttpStatus.UNAUTHORIZED, errorDetails); + } + + /** + * FORBIDDEN (403) 예외 생성 + */ + public static UserDomainException forbidden(String message, String errorCode) { + return new UserDomainException(message, errorCode, HttpStatus.FORBIDDEN); + } + + public static UserDomainException forbidden(String message, String errorCode, Map errorDetails) { + return new UserDomainException(message, errorCode, HttpStatus.FORBIDDEN, errorDetails); + } + + /** + * NOT_FOUND (404) 예외 생성 + */ + public static UserDomainException notFound(String message, String errorCode) { + return new UserDomainException(message, errorCode, HttpStatus.NOT_FOUND); + } + + public static UserDomainException notFound(String message, String errorCode, Map errorDetails) { + return new UserDomainException(message, errorCode, HttpStatus.NOT_FOUND, errorDetails); + } + + /** + * CONFLICT (409) 예외 생성 + */ + public static UserDomainException conflict(String message, String errorCode) { + return new UserDomainException(message, errorCode, HttpStatus.CONFLICT); + } + + public static UserDomainException conflict(String message, String errorCode, Map errorDetails) { + return new UserDomainException(message, errorCode, HttpStatus.CONFLICT, errorDetails); } - public static UserDomainException emailDuplicate(String email) { - return new UserDomainException( - "이미 존재하는 이메일입니다: " + email, - "EMAIL_ALREADY_EXISTS", - HttpStatus.CONFLICT - ); + /** + * UNPROCESSABLE_ENTITY (422) 예외 생성 + */ + public static UserDomainException unprocessableEntity(String message, String errorCode) { + return new UserDomainException(message, errorCode, HttpStatus.UNPROCESSABLE_ENTITY); } - public static UserDomainException nicknameDuplicate(String nickname) { - return new UserDomainException( - "이미 존재하는 닉네임입니다: " + nickname, - "NICKNAME_ALREADY_EXISTS", - HttpStatus.CONFLICT - ); + public static UserDomainException unprocessableEntity(String message, String errorCode, Map errorDetails) { + return new UserDomainException(message, errorCode, HttpStatus.UNPROCESSABLE_ENTITY, errorDetails); } - public static UserDomainException unauthorized() { - return new UserDomainException( - "접근 권한이 없습니다", - "UNAUTHORIZED_ACCESS", - HttpStatus.FORBIDDEN - ); + /** + * INTERNAL_SERVER_ERROR (500) 예외 생성 + */ + public static UserDomainException internalServerError(String message, String errorCode) { + return new UserDomainException(message, errorCode, HttpStatus.INTERNAL_SERVER_ERROR); } - public static UserDomainException invalidData(String field, String reason) { - return new UserDomainException( - String.format("유효하지 않은 데이터 - %s: %s", field, reason), - "USER_INVALID_DATA", - HttpStatus.BAD_REQUEST - ); + public static UserDomainException internalServerError(String message, String errorCode, Map errorDetails) { + return new UserDomainException(message, errorCode, HttpStatus.INTERNAL_SERVER_ERROR, errorDetails); } -} +} \ No newline at end of file diff --git a/src/main/java/com/fastcampus/book_bot/common/exception/user/UserErrorCode.java b/src/main/java/com/fastcampus/book_bot/common/exception/user/UserErrorCode.java new file mode 100644 index 0000000..76f6b84 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/common/exception/user/UserErrorCode.java @@ -0,0 +1,53 @@ +package com.fastcampus.book_bot.common.exception.user; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum UserErrorCode { + + // ============== 인증/인가 관련 ============== + LOGIN_FAILED("USER_LOGIN_FAILED", "로그인에 실패했습니다"), + UNAUTHORIZED_ACCESS("USER_UNAUTHORIZED_ACCESS", "접근 권한이 없습니다"), + INVALID_CREDENTIALS("USER_INVALID_CREDENTIALS", "이메일 또는 비밀번호가 올바르지 않습니다"), + PASSWORD_MISMATCH("USER_PASSWORD_MISMATCH", "비밀번호가 일치하지 않습니다"), + ACCESS_DENIED("USER_ACCESS_DENIED", "접근이 거부되었습니다"), + + // ============== 데이터 검증 관련 ============== + INVALID_DATA("USER_INVALID_DATA", "유효하지 않은 데이터입니다"), + INVALID_EMAIL_FORMAT("USER_INVALID_EMAIL_FORMAT", "올바르지 않은 이메일 형식입니다"), + INVALID_PASSWORD_FORMAT("USER_INVALID_PASSWORD_FORMAT", "비밀번호 형식이 올바르지 않습니다"), + INVALID_PHONE_FORMAT("USER_INVALID_PHONE_FORMAT", "올바르지 않은 전화번호 형식입니다"), + INVALID_NICKNAME_FORMAT("USER_INVALID_NICKNAME_FORMAT", "올바르지 않은 닉네임 형식입니다"), + + // ============== 중복 관련 ============== + EMAIL_ALREADY_EXISTS("USER_EMAIL_ALREADY_EXISTS", "이미 존재하는 이메일입니다"), + NICKNAME_ALREADY_EXISTS("USER_NICKNAME_ALREADY_EXISTS", "이미 존재하는 닉네임입니다"), + + // ============== 조회 관련 ============== + NOT_FOUND("USER_NOT_FOUND", "사용자를 찾을 수 없습니다"), + EMAIL_NOT_FOUND("USER_EMAIL_NOT_FOUND", "해당 이메일의 사용자를 찾을 수 없습니다"), + + // ============== 이메일 인증 관련 ============== + EMAIL_NOT_VERIFIED("USER_EMAIL_NOT_VERIFIED", "이메일 인증이 완료되지 않았습니다"), + VERIFICATION_CODE_EXPIRED("USER_VERIFICATION_CODE_EXPIRED", "인증코드가 만료되었습니다"), + VERIFICATION_CODE_INVALID("USER_VERIFICATION_CODE_INVALID", "올바르지 않은 인증코드입니다"), + EMAIL_SEND_FAILED("USER_EMAIL_SEND_FAILED", "이메일 전송에 실패했습니다"), + + // ============== 약관/정책 관련 ============== + TERMS_NOT_AGREED("USER_TERMS_NOT_AGREED", "약관에 동의해야 합니다"), + PRIVACY_POLICY_NOT_AGREED("USER_PRIVACY_POLICY_NOT_AGREED", "개인정보 처리방침에 동의해야 합니다"), + + // ============== 계정 상태 관련 ============== + ACCOUNT_INACTIVE("USER_ACCOUNT_INACTIVE", "비활성화된 계정입니다"), + ACCOUNT_SUSPENDED("USER_ACCOUNT_SUSPENDED", "정지된 계정입니다"), + ACCOUNT_LOCKED("USER_ACCOUNT_LOCKED", "잠긴 계정입니다"), + + // ============== 시스템 에러 ============== + SYSTEM_ERROR("USER_SYSTEM_ERROR", "시스템 오류가 발생했습니다"), + DATABASE_ERROR("USER_DATABASE_ERROR", "데이터베이스 오류가 발생했습니다"); + + private final String code; + private final String message; +} diff --git a/src/main/java/com/fastcampus/book_bot/common/response/ErrorApiResponse.java b/src/main/java/com/fastcampus/book_bot/common/response/ErrorApiResponse.java index 78aa4f6..6044e3f 100644 --- a/src/main/java/com/fastcampus/book_bot/common/response/ErrorApiResponse.java +++ b/src/main/java/com/fastcampus/book_bot/common/response/ErrorApiResponse.java @@ -3,38 +3,37 @@ import lombok.Getter; import java.time.LocalDateTime; +import java.util.Map; /** - * 에러 API 응답을 나타내는 클래스 - * - * @param 상세한 에러 정보 타입 + * 에러에 대한 API 응답을 나타내는 클래스 */ @Getter -public class ErrorApiResponse extends BaseApiResponse { +public class ErrorApiResponse extends BaseApiResponse { private final String errorCode; - private final T errorDetail; + private final Map errorDetail; - public ErrorApiResponse(String message, String errorCode, T errorDetail) { + public ErrorApiResponse(String message, String errorCode, Map errorDetail) { super(false, message, LocalDateTime.now()); this.errorCode = errorCode; this.errorDetail = errorDetail; } - public static ErrorApiResponse message(String message) { - return new ErrorApiResponse<>(message, null, null); + public static ErrorApiResponse message(String message) { + return new ErrorApiResponse(message, null, null); } - public static ErrorApiResponse of(String message, String errorCode) { - return new ErrorApiResponse<>(message, errorCode, null); + public static ErrorApiResponse of(String message, String errorCode) { + return new ErrorApiResponse(message, errorCode, null); } /** * @param message 응답 메시지 * @param errorCode 에러 코드 - * @param errorDetail 상세한 에러 정보 + * @param errorDetail 상세한 에러 정보 (key-value 형태) */ - public static ErrorApiResponse of(String message, String errorCode, T errorDetail) { - return new ErrorApiResponse<>(message, errorCode, errorDetail); + public static ErrorApiResponse of(String message, String errorCode, Map errorDetail) { + return new ErrorApiResponse(message, errorCode, errorDetail); } } diff --git a/src/main/java/com/fastcampus/book_bot/common/response/SuccessApiResponse.java b/src/main/java/com/fastcampus/book_bot/common/response/SuccessApiResponse.java index d21bec0..2a9354f 100644 --- a/src/main/java/com/fastcampus/book_bot/common/response/SuccessApiResponse.java +++ b/src/main/java/com/fastcampus/book_bot/common/response/SuccessApiResponse.java @@ -12,26 +12,27 @@ @Getter public class SuccessApiResponse extends BaseApiResponse { - private final T data; + private T data; + + public SuccessApiResponse(String message) { + super(true, message, LocalDateTime.now()); + } public SuccessApiResponse(String message, T data) { super(true, message, LocalDateTime.now()); this.data = data; } - public static SuccessApiResponse of(T data) { - return new SuccessApiResponse<>(null, data); + public static SuccessApiResponse of(String message) { + return new SuccessApiResponse<>(message); } /** * @param data 응답 데이터 * @param message 응답 메시지 */ - public static SuccessApiResponse of(T data, String message) { + public static SuccessApiResponse of(String message, T data) { return new SuccessApiResponse<>(message, data); } - public static SuccessApiResponse message(String message) { - return new SuccessApiResponse<>(message, null); - } } \ No newline at end of file diff --git a/src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java b/src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java index f2bdae6..6e04c80 100644 --- a/src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java +++ b/src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java @@ -1,10 +1,14 @@ package com.fastcampus.book_bot.controller.auth; +import com.fastcampus.book_bot.common.exception.user.UserDomainException; +import com.fastcampus.book_bot.common.exception.user.UserErrorCode; import com.fastcampus.book_bot.common.response.ApiResponse; +import com.fastcampus.book_bot.common.response.SuccessApiResponse; import com.fastcampus.book_bot.dto.user.SignupRequestDTO; import com.fastcampus.book_bot.service.auth.AuthService; import com.fastcampus.book_bot.service.auth.MailService; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -24,55 +28,48 @@ public ResponseEntity> login(@RequestBody SignupRequestDTO req return ResponseEntity.ok(ApiResponse.success("로그인 완료!")); } - /* 회원가입 */ + /** 회원가입 + * @param request 회원가입 DTO + */ @PostMapping("/signup") - public ResponseEntity> signup(@RequestBody SignupRequestDTO request) { + public ResponseEntity> signup(@RequestBody SignupRequestDTO request) { authService.signup(request); - return ResponseEntity.ok(ApiResponse.success("회원가입 완료!")); + return ResponseEntity.status(HttpStatus.CREATED) + .body(SuccessApiResponse.of("회원가입이 완료되었습니다.")); } /* 닉네임 중복 */ @GetMapping("/check-nickname") - public ResponseEntity> checkNickname(@RequestParam String nickname) { + public ResponseEntity> checkNickname(@RequestParam String nickname) { boolean response = authService.isDuplicateNickname(nickname); - if (response) { - return ResponseEntity.ok(ApiResponse.success("중복된 닉네임입니다.")); - } else { - return ResponseEntity.ok(ApiResponse.success("가능한!")); - } + String message = response ? "이미 사용 중인 이메일입니다" : "사용 가능한 이메일입니다"; + return ResponseEntity.ok(SuccessApiResponse.of(message)); } /* 이메일 중복 */ @GetMapping("/check-email") - public ResponseEntity> checkEmail(@RequestParam String userEmail) { + public ResponseEntity> checkEmail(@RequestParam String userEmail) { boolean response = authService.isDuplicateEmail(userEmail); - if (response) { - return ResponseEntity.ok(ApiResponse.success("중복된 이메일입니다.")); - } else { - return ResponseEntity.ok(ApiResponse.success("가능한!")); - } + String message = response ? "이미 사용 중인 닉네임입니다" : "사용 가능한 닉네임입니다"; + return ResponseEntity.ok(SuccessApiResponse.of(message)); } /* 이메일 전송 */ @PostMapping("/send-verification") - public ResponseEntity> sendEmail(@RequestParam String userEmail) { + public ResponseEntity> sendEmail(@RequestParam String userEmail) { mailService.sendVerificationCode(userEmail); - return ResponseEntity.ok(ApiResponse.success("이메일 전송이 완료되었습니다.")); + return ResponseEntity.ok(SuccessApiResponse.of("인증코드가 전송되었습니다.")); } /* 이메일 검증 */ @PostMapping("/verify-email") - public ResponseEntity> verifyEmail(@RequestBody SignupRequestDTO signupRequest) { - boolean response = mailService.verifyCode(signupRequest.getEmail(), signupRequest.getVerificationCode()); + public ResponseEntity> verifyEmail(@RequestBody SignupRequestDTO signupRequest) { + mailService.verifyCode(signupRequest.getEmail(), signupRequest.getVerificationCode()); - if (response) { - return ResponseEntity.ok(ApiResponse.success("이메일 검증 성공")); - } else { - return ResponseEntity.ok(ApiResponse.success("인증번호가 틀립니다")); - } + return ResponseEntity.ok(SuccessApiResponse.of("이메일 검증 성공")); } } diff --git a/src/main/java/com/fastcampus/book_bot/service/auth/AuthService.java b/src/main/java/com/fastcampus/book_bot/service/auth/AuthService.java index 1caf841..e7b0cf2 100644 --- a/src/main/java/com/fastcampus/book_bot/service/auth/AuthService.java +++ b/src/main/java/com/fastcampus/book_bot/service/auth/AuthService.java @@ -1,5 +1,7 @@ package com.fastcampus.book_bot.service.auth; +import com.fastcampus.book_bot.common.exception.user.UserDomainException; +import com.fastcampus.book_bot.common.exception.user.UserErrorCode; import com.fastcampus.book_bot.common.utils.EncoderUtils; import com.fastcampus.book_bot.domain.user.User; import com.fastcampus.book_bot.dto.user.SignupRequestDTO; @@ -9,6 +11,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Map; + @Service @Slf4j @RequiredArgsConstructor @@ -17,87 +21,156 @@ public class AuthService { private final UserRepository userRepository; private final EncoderUtils encoderUtils; - /* 닉네임 중복 검증 */ + /** + * 닉네임 중복 검증 + */ @Transactional(readOnly = true) public boolean isDuplicateNickname(String nickname) { + if (nickname == null || nickname.trim().isEmpty()) { + throw UserDomainException.badRequest( + UserErrorCode.INVALID_DATA.getMessage(), + UserErrorCode.INVALID_DATA.getCode() + ); + } + try { return userRepository.existsByUserNickname(nickname); - } catch (Exception e) { - log.error("닉네임 중복 검증 실패", e); - throw new RuntimeException("닉네임 중복 확인 중 오류 발생", e); + log.error("닉네임 중복 검증 실패: {}", nickname, e); + throw UserDomainException.internalServerError( + UserErrorCode.SYSTEM_ERROR.getMessage(), + UserErrorCode.SYSTEM_ERROR.getCode(), + Map.of("field", "nickname", "value", nickname) + ); } } - /* 이메일 중복 검증 */ + /** + * 이메일 중복 검증 + */ @Transactional(readOnly = true) public boolean isDuplicateEmail(String email) { + if (email == null || email.trim().isEmpty()) { + throw UserDomainException.badRequest( + UserErrorCode.INVALID_DATA.getMessage(), + UserErrorCode.INVALID_DATA.getCode() + ); + } + try { return userRepository.existsByUserEmail(email); - } catch (Exception e) { - log.error("이메일 중복 검증 실패"); - throw new RuntimeException("이메일 중복 확인 중 오류 발생", e); + log.error("이메일 중복 검증 실패: {}", email, e); + throw UserDomainException.internalServerError( + UserErrorCode.SYSTEM_ERROR.getMessage(), + UserErrorCode.SYSTEM_ERROR.getCode(), + Map.of("field", "email", "value", email) + ); } } - /* 회원 가입 */ + /** + * 회원가입 처리 + */ @Transactional public void signup(SignupRequestDTO request) { try { - if (isDuplicateNickname(request.getNickname())) { - throw new RuntimeException("이미 사용중인 이메일입니다."); + if (isDuplicateEmail(request.getEmail())) { + throw UserDomainException.conflict( + "이미 존재하는 이메일입니다: " + request.getEmail(), + UserErrorCode.EMAIL_ALREADY_EXISTS.getCode(), + Map.of("email", request.getEmail()) + ); } if (isDuplicateNickname(request.getNickname())) { - throw new RuntimeException("이미 사용 중인 닉네임입니다."); - } - - if (!request.isAgreeTerms()) { - throw new RuntimeException("약관에 동의해야 합니다."); - } - - if (!request.isEmailVerified()) { - throw new RuntimeException("이메일 인증이 완료되지 않았습니다."); - } - - if (!request.getPassword().equals(request.getPasswordConfirm())) { - throw new RuntimeException("비밀번호가 일치하지 않습니다."); + throw UserDomainException.conflict( + "이미 존재하는 닉네임입니다: " + request.getNickname(), + UserErrorCode.NICKNAME_ALREADY_EXISTS.getCode(), + Map.of("nickname", request.getNickname()) + ); } User user = User.builder() - .userEmail(request.getEmail()) - .userPassword(encoderUtils.passwordEncode(request.getPassword())) - .userName(request.getName()) - .userNickname(request.getNickname()) - .userPhone(request.getPhone()) - .userStatus("ACTIVE") - .build(); + .userEmail(request.getEmail()) + .userPassword(encoderUtils.passwordEncode(request.getPassword())) + .userName(request.getName()) + .userNickname(request.getNickname()) + .userPhone(request.getPhone()) + .userStatus("ACTIVE") + .build(); userRepository.save(user); + log.info("회원가입 완료: {}", request.getEmail()); + } catch (UserDomainException e) { + throw e; } catch (Exception e) { - throw new RuntimeException("회원가입 중 오류 발생!", e); + log.error("회원가입 처리 중 오류 발생: {}", request.getEmail(), e); + throw UserDomainException.internalServerError( + UserErrorCode.SYSTEM_ERROR.getMessage(), + UserErrorCode.SYSTEM_ERROR.getCode(), + Map.of("email", request.getEmail()) + ); } } @Transactional(readOnly = true) - public void login(SignupRequestDTO request) { + public User login(SignupRequestDTO request) { + if (request.getEmail() == null || request.getEmail().trim().isEmpty()) { + throw UserDomainException.badRequest( + "이메일은 필수입니다", + UserErrorCode.INVALID_DATA.getCode() + ); + } + + if (request.getPassword() == null || request.getPassword().trim().isEmpty()) { + throw UserDomainException.badRequest( + "비밀번호는 필수입니다", + UserErrorCode.INVALID_DATA.getCode() + ); + } try { User user = userRepository.findByUserEmail(request.getEmail()); if (user == null) { - throw new RuntimeException("이메일 또는 비밀번호를 확인해주세요"); + throw UserDomainException.unauthorized( + UserErrorCode.INVALID_CREDENTIALS.getMessage(), + UserErrorCode.INVALID_CREDENTIALS.getCode(), + Map.of("email", request.getEmail()) + ); } if (!encoderUtils.validatePassword(request.getPassword(), user.getUserPassword())) { - throw new RuntimeException("이메일 또는 비밀번호를 확인해주세요"); + throw UserDomainException.unauthorized( + UserErrorCode.INVALID_CREDENTIALS.getMessage(), + UserErrorCode.INVALID_CREDENTIALS.getCode(), + Map.of("email", request.getEmail()) + ); } + if (!"ACTIVE".equals(user.getUserStatus())) { + throw UserDomainException.forbidden( + UserErrorCode.ACCOUNT_INACTIVE.getMessage(), + UserErrorCode.ACCOUNT_INACTIVE.getCode(), + Map.of("status", user.getUserStatus(), "email", request.getEmail()) + ); + } + + log.info("로그인 성공: {}", request.getEmail()); + return user; + + } catch (UserDomainException e) { + throw e; } catch (Exception e) { - throw new RuntimeException("로그인 중 오류 발생!", e); + log.error("로그인 처리 중 오류 발생: {}", request.getEmail(), e); + throw UserDomainException.internalServerError( + UserErrorCode.SYSTEM_ERROR.getMessage(), + UserErrorCode.SYSTEM_ERROR.getCode(), + Map.of("email", request.getEmail()) + ); } } diff --git a/src/main/java/com/fastcampus/book_bot/service/auth/MailService.java b/src/main/java/com/fastcampus/book_bot/service/auth/MailService.java index d1aafe0..85eacea 100644 --- a/src/main/java/com/fastcampus/book_bot/service/auth/MailService.java +++ b/src/main/java/com/fastcampus/book_bot/service/auth/MailService.java @@ -1,6 +1,7 @@ package com.fastcampus.book_bot.service.auth; -import com.fastcampus.book_bot.common.response.ApiResponse; +import com.fastcampus.book_bot.common.exception.user.UserDomainException; +import com.fastcampus.book_bot.common.exception.user.UserErrorCode; import jakarta.mail.internet.MimeMessage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -12,6 +13,7 @@ import org.thymeleaf.context.Context; import java.time.Duration; +import java.util.Map; import java.util.Random; @Service @@ -21,13 +23,14 @@ public class MailService { private final TemplateEngine templateEngine; private final JavaMailSender javaMailSender; - private final RedisTemplate redisTemplaRte; + private final RedisTemplate redisTemplate; private static final String EMAIL_VERIFICATION_PREFIX = "email_verification:"; private static final int VERIFICATION_EXPIRE_MINUTES = 10; - private final RedisTemplate redisTemplate; - /* 이메일 보내기 */ + /** 이메일 보내기 + * @param userEmail 사용자 이메일 + * */ private Integer sendMailMessage(String userEmail) { MimeMessage message = javaMailSender.createMimeMessage(); @@ -46,35 +49,79 @@ private Integer sendMailMessage(String userEmail) { return code; } catch (Exception e) { - log.error("메일 전송 실패", e); - throw new RuntimeException("메일 전송 실패"); + log.error("메일 전송 실패: {}", userEmail, e); + throw UserDomainException.unprocessableEntity( + UserErrorCode.EMAIL_SEND_FAILED.getMessage(), + UserErrorCode.EMAIL_SEND_FAILED.getCode(), + Map.of("email", userEmail) + ); } - } - /* redis에 이메일, 인증 번호 저장 */ + /** redis에 이메일, 인증 번호 저장 + * @param userEmail 사용자 이메일 + * */ public void sendVerificationCode(String userEmail) { + if (userEmail == null || userEmail.trim().isEmpty()) { + throw UserDomainException.badRequest( + UserErrorCode.INVALID_DATA.getMessage(), + UserErrorCode.INVALID_DATA.getCode() + ); + } - Integer code = sendMailMessage(userEmail); - String key = EMAIL_VERIFICATION_PREFIX + userEmail; - redisTemplaRte.opsForValue().set(key, code.toString(), Duration.ofMinutes(VERIFICATION_EXPIRE_MINUTES)); + try { + Integer code = sendMailMessage(userEmail); + String key = EMAIL_VERIFICATION_PREFIX + userEmail; + redisTemplate.opsForValue().set(key, code.toString(), Duration.ofMinutes(VERIFICATION_EXPIRE_MINUTES)); + } catch (UserDomainException e) { + throw e; + } catch (Exception e) { + log.error("인증코드 전송 실패: {}", userEmail, e); + throw UserDomainException.internalServerError( + UserErrorCode.SYSTEM_ERROR.getMessage(), + UserErrorCode.SYSTEM_ERROR.getCode(), + Map.of("email", userEmail) + ); + } } - /* redis에서 인증 번호 검증 */ + /** redis 인증 번호 검증 + * @param userEmail 사용자 이메일 + * @param inputCode 인증 번호 + * */ public boolean verifyCode(String userEmail, String inputCode) { + if (userEmail == null || userEmail.trim().isEmpty()) { + throw UserDomainException.badRequest( + UserErrorCode.INVALID_DATA.getMessage(), + UserErrorCode.INVALID_DATA.getCode() + ); + } + + if (inputCode == null || inputCode.trim().isEmpty()) { + throw UserDomainException.badRequest( + UserErrorCode.INVALID_DATA.getMessage(), + UserErrorCode.INVALID_DATA.getCode() + ); + } + try { String key = EMAIL_VERIFICATION_PREFIX + userEmail; - String storedCode = redisTemplaRte.opsForValue().get(key); + String storedCode = redisTemplate.opsForValue().get(key); if (storedCode != null && storedCode.equals(inputCode)) { redisTemplate.delete(key); return true; } else { - return false; + throw UserDomainException.badRequest(UserErrorCode.VERIFICATION_CODE_INVALID.getMessage(), + UserErrorCode.VERIFICATION_CODE_INVALID.getCode()); } } catch (Exception e) { log.error("인증 코드 검증 실패: {}", userEmail, e); - throw new RuntimeException("인증 코드 검증 실패"); + throw UserDomainException.internalServerError( + UserErrorCode.SYSTEM_ERROR.getMessage(), + UserErrorCode.SYSTEM_ERROR.getCode(), + Map.of("email", userEmail) + ); } } -} +} \ No newline at end of file From 7ce1ffffe78737d8fe4b93148dc5644a293468a4 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Fri, 15 Aug 2025 20:17:05 +0900 Subject: [PATCH 18/27] =?UTF-8?q?chore=20:=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EB=B0=8F=20html=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/config/WebClientConfig.java | 2 +- .../exception/user/UserDomainException.java | 2 - src/main/resources/templates/auth/signup.html | 69 +++++++++++++------ 3 files changed, 49 insertions(+), 24 deletions(-) diff --git a/src/main/java/com/fastcampus/book_bot/common/config/WebClientConfig.java b/src/main/java/com/fastcampus/book_bot/common/config/WebClientConfig.java index 3932b1f..8d3e509 100644 --- a/src/main/java/com/fastcampus/book_bot/common/config/WebClientConfig.java +++ b/src/main/java/com/fastcampus/book_bot/common/config/WebClientConfig.java @@ -16,7 +16,7 @@ @Configuration public class WebClientConfig { - /* 네이버 API를 호출하기 위한 WebClient 설정 + /** 네이버 API를 호출하기 위한 WebClient 설정 - 기본 URL: https://openapi.naver.com/v1/search - 요청 헤더: Content-Type을 application/json으로 설정 - 최대 메모리 크기: 4MB로 설정 diff --git a/src/main/java/com/fastcampus/book_bot/common/exception/user/UserDomainException.java b/src/main/java/com/fastcampus/book_bot/common/exception/user/UserDomainException.java index 4ca510c..411b54d 100644 --- a/src/main/java/com/fastcampus/book_bot/common/exception/user/UserDomainException.java +++ b/src/main/java/com/fastcampus/book_bot/common/exception/user/UserDomainException.java @@ -16,8 +16,6 @@ public UserDomainException(String message, String errorCode, super(message, errorCode, "USER", httpStatus, errorDetails); } - // ============== HttpStatus 기반 범용 팩토리 메서드 ============== - /** * BAD_REQUEST (400) 예외 생성 */ diff --git a/src/main/resources/templates/auth/signup.html b/src/main/resources/templates/auth/signup.html index 1e5b15d..a5f110a 100644 --- a/src/main/resources/templates/auth/signup.html +++ b/src/main/resources/templates/auth/signup.html @@ -431,7 +431,7 @@

회원가입

maxlength="6">
- 이메일로 발송된 인증번호를 입력해주세요. (유효시간: 5:00) + 이메일로 발송된 인증번호를 입력해주세요. (유효시간: 10:00) @@ -545,7 +545,7 @@

회원가입

// 전역 변수 let emailVerified = false; let verificationTimer = null; - let verificationTimeLeft = 300; // 5분 + let verificationTimeLeft = 600; // 10분 // 회원가입 처리 함수 async function handleSignup(event) { @@ -578,11 +578,7 @@

회원가입

nickname: formData.get('nickname'), email: formData.get('email'), password: formData.get('password'), - passwordConfirm: formData.get('passwordConfirm'), - phone: formData.get('phone'), - birthDate: formData.get('birthDate'), - agreeTerms: formData.get('agreeTerms') === 'on', - emailVerified: emailVerified + phone: formData.get('phone') || null }; try { @@ -714,10 +710,9 @@

회원가입

const response = await fetch(`/api/member/check-email?userEmail=${encodeURIComponent(email)}`); const result = await response.json(); - // ApiResponse 기반 응답 처리 if (result.success) { - // 컨트롤러에서 중복일 때 "중복된 이메일입니다." 메시지를 반환 - if (result.message && result.message.includes('중복')) { + // 컨트롤러 로직에서 중복일 때 "이미 사용 중인 닉네임입니다", 사용가능일 때 "사용 가능한 닉네임입니다" 반환 + if (result.message && result.message.includes('이미 사용 중인')) { showFieldFeedback('emailFeedback', result.message, 'error'); sendBtn.disabled = true; } else { @@ -756,10 +751,9 @@

회원가입

const response = await fetch(`/api/member/check-nickname?nickname=${encodeURIComponent(nickname)}`); const result = await response.json(); - // ApiResponse 기반 응답 처리 if (result.success) { - // 컨트롤러에서 중복일 때 "중복된 닉네임입니다." 메시지를 반환 - if (result.message && result.message.includes('중복')) { + // 컨트롤러 로직에서 중복일 때 "이미 사용 중인 이메일입니다", 사용가능일 때 "사용 가능한 이메일입니다" 반환 + if (result.message && result.message.includes('이미 사용 중인')) { showFieldFeedback('nicknameFeedback', result.message, 'error'); } else { showFieldFeedback('nicknameFeedback', result.message, 'success'); @@ -866,14 +860,16 @@

회원가입

const result = await response.json(); if (result.success) { - // 인증 성공 메시지 확인 (컨트롤러에서 "이메일 검증 성공" 반환) - if (result.message && result.message.includes('성공')) { - emailVerified = true; - showSuccess('이메일 인증이 완료되었습니다!'); - showFieldFeedback('emailFeedback', '인증 완료 ✓', 'success'); - } else { - // 인증 실패 ("인증번호가 틀립니다" 메시지) - showError(result.message); + emailVerified = true; + showSuccess(result.message); + showFieldFeedback('emailFeedback', '인증 완료 ✓', 'success'); + + // 인증 완료 후 인증 영역 숨기기 + document.getElementById('emailVerification').style.display = 'none'; + + // 타이머 정지 + if (verificationTimer) { + clearInterval(verificationTimer); } } else { showError(result.message || '이메일 인증에 실패했습니다.'); @@ -885,6 +881,37 @@

회원가입

} } + // 인증 타이머 시작 + function startVerificationTimer() { + verificationTimeLeft = 600; // 10분 + + if (verificationTimer) { + clearInterval(verificationTimer); + } + + verificationTimer = setInterval(() => { + verificationTimeLeft--; + + const minutes = Math.floor(verificationTimeLeft / 60); + const seconds = verificationTimeLeft % 60; + const timerDisplay = `${minutes}:${seconds.toString().padStart(2, '0')}`; + + const timerElement = document.getElementById('timer'); + timerElement.textContent = timerDisplay; + + if (verificationTimeLeft <= 60) { + timerElement.classList.add('timer-warning'); + } + + if (verificationTimeLeft <= 0) { + clearInterval(verificationTimer); + timerElement.textContent = '시간 만료'; + showError('인증시간이 만료되었습니다. 다시 인증번호를 요청해주세요.'); + document.getElementById('emailVerification').style.display = 'none'; + } + }, 1000); + } + // 페이지 로드 시 이벤트 리스너 등록 document.addEventListener('DOMContentLoaded', function() { // 인증번호 입력 시 숫자만 허용 From b4e1ed994b8a1b49f2d91c403e105554d8287e1a Mon Sep 17 00:00:00 2001 From: JiHoon Date: Sat, 16 Aug 2025 20:39:34 +0900 Subject: [PATCH 19/27] =?UTF-8?q?update=20:=20JWT=20=EC=9D=B8=EC=A6=9D/?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8/=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=EC=97=90=EB=8F=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 22 +++ .../book_bot/common/utils/JwtUtil.java | 110 +++++++++++++++ .../controller/auth/AuthController.java | 63 ++++++++- .../service/auth/AuthRedisService.java | 130 ++++++++++++++++++ src/main/resources/templates/auth/login.html | 129 +++++++++++------ 5 files changed, 405 insertions(+), 49 deletions(-) create mode 100644 src/main/java/com/fastcampus/book_bot/common/utils/JwtUtil.java create mode 100644 src/main/java/com/fastcampus/book_bot/service/auth/AuthRedisService.java diff --git a/build.gradle b/build.gradle index 2fc8f1d..4c541c4 100644 --- a/build.gradle +++ b/build.gradle @@ -24,21 +24,43 @@ repositories { } dependencies { + // Thymeleaf 템플릿 엔진 implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + // Spring MVC 웹 애플리케이션 implementation 'org.springframework.boot:spring-boot-starter-web' + // Spring Data JPA (ORM) implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + // Spring WebFlux (비동기/리액티브 웹) implementation 'org.springframework.boot:spring-boot-starter-webflux' + // Bean Validation (입력값 검증) implementation 'org.springframework.boot:spring-boot-starter-validation' + // 이메일 발송 기능 implementation 'org.springframework.boot:spring-boot-starter-mail' + // Redis 데이터베이스 연동 implementation 'org.springframework.boot:spring-boot-starter-data-redis' + // 암호화 및 해싱 기능 implementation 'org.springframework.security:spring-security-crypto:5.7.2' + // 데이터베이스 마이그레이션 도구 (Flyway) implementation 'org.flywaydb:flyway-core' + // MySQL용 Flyway 지원 implementation 'org.flywaydb:flyway-mysql' + // 컴파일 시 Lombok 사용 (코드 자동 생성) compileOnly 'org.projectlombok:lombok' + // MySQL JDBC 드라이버 (런타임) runtimeOnly 'com.mysql:mysql-connector-j' + // Lombok 애노테이션 프로세서 annotationProcessor 'org.projectlombok:lombok' + // 테스트용 Spring Boot 스타터 testImplementation 'org.springframework.boot:spring-boot-starter-test' + // JUnit 플랫폼 런처 (테스트 실행) testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // JWT 토큰 생성/파싱/검증 API + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + // JWT 구현체 + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + // JWT JSON 처리(Jackson 연동) + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' } tasks.named('test') { diff --git a/src/main/java/com/fastcampus/book_bot/common/utils/JwtUtil.java b/src/main/java/com/fastcampus/book_bot/common/utils/JwtUtil.java new file mode 100644 index 0000000..4c86647 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/common/utils/JwtUtil.java @@ -0,0 +1,110 @@ +package com.fastcampus.book_bot.common.utils; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.security.Key; +import java.util.Date; +import java.util.function.Function; + +@Component +@Data +@ConfigurationProperties(prefix = "jwt") +public class JwtUtil { + + private String secretKey; + private Long expirationTime; + private Long refreshExpirationTime; + private String issuer; + + /** JWT 서명용 키 생성 + * Base64로 인코딩된 비밀키를 디코딩하여 HMAC SHA256 키로 변환 + * */ + private Key getSignatureKey() { + byte[] keyBytes = Decoders.BASE64.decode(secretKey); + return Keys.hmacShaKeyFor(keyBytes); + } + + /** Access Token 생성 메서드 + * 사용자명과 권한 정보를 포함한 JWT 토큰 생성 + * @param roles 권한 명 + * */ + public String createAccessToken(Integer userId, String roles) { + Date now = new Date(); + Date expirationDate = new Date(now.getTime() + expirationTime); + + return Jwts.builder() + .setSubject(userId.toString()) + .claim("roles", roles) + .setIssuer(issuer) + .setIssuedAt(now) + .setExpiration(expirationDate) + .signWith(getSignatureKey(), SignatureAlgorithm.HS256) + .compact(); + } + + /** Refresh Token 생성 메서드 + * 사용자 명만 포함하고 권한정보는 제외 + * */ + public String createRefreshToken(Integer userId) { + Date now = new Date(); + Date expirationDate = new Date(now.getTime() + refreshExpirationTime); + + return Jwts.builder() + .setSubject(userId.toString()) + .setIssuer(issuer) + .setIssuedAt(now) + .setExpiration(expirationDate) + .signWith(getSignatureKey(), SignatureAlgorithm.HS256) + .compact(); + } + + /** JWT에서 특정 클레임 추출하는 메서드 + * @param claimsResolver Claims에서 원하는 정보를 추출 + * */ + public T extractClaim(String token, Function claimsResolver) { + final Claims claims = Jwts.parserBuilder() + .setSigningKey(getSignatureKey()) + .build() + .parseClaimsJws(token) + .getBody(); + + return claimsResolver.apply(claims); + } + + /** JWT에서 사용자 ID 추출 + * @param token JWT 토큰 문자열 + * */ + public Integer extractUserId(String token) { + String userId = extractClaim(token, Claims::getSubject); + + return Integer.parseInt(userId); + } + + /** JWT에서 권한 추출 + * */ + public String extractRoles(String token) { + + return extractClaim(token, claims -> claims.get("roles", String.class)); + } + + /** JWT 만료여부 확인 + * */ + public boolean isTokenExpired(String token) { + return extractClaim(token, Claims::getExpiration).before(new Date()); + } + + /** JWT 유효성 검증 + * */ + public boolean validateToken(String token, Integer userId) { + final Integer tokenUserId = extractUserId(token); + return (tokenUserId.equals(userId) && !isTokenExpired(token)); + } + +} diff --git a/src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java b/src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java index 6e04c80..2f79d5a 100644 --- a/src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java +++ b/src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java @@ -1,17 +1,21 @@ package com.fastcampus.book_bot.controller.auth; -import com.fastcampus.book_bot.common.exception.user.UserDomainException; -import com.fastcampus.book_bot.common.exception.user.UserErrorCode; -import com.fastcampus.book_bot.common.response.ApiResponse; import com.fastcampus.book_bot.common.response.SuccessApiResponse; +import com.fastcampus.book_bot.common.utils.JwtUtil; +import com.fastcampus.book_bot.domain.user.User; import com.fastcampus.book_bot.dto.user.SignupRequestDTO; +import com.fastcampus.book_bot.service.auth.AuthRedisService; import com.fastcampus.book_bot.service.auth.AuthService; import com.fastcampus.book_bot.service.auth.MailService; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.HashMap; +import java.util.Map; + @RestController @RequestMapping("/api/member") @RequiredArgsConstructor @@ -19,13 +23,58 @@ public class AuthController { private final MailService mailService; private final AuthService authService; + private final AuthRedisService authRedisService; + private final JwtUtil jwtUtil; - /* 로그인 */ + /** 로그인 + * @param request SignupRequestDTO + * @param response 쿠키 저장 + */ @PostMapping("/login") - public ResponseEntity> login(@RequestBody SignupRequestDTO request) { - authService.login(request); + public ResponseEntity>> login(@RequestBody SignupRequestDTO request, + HttpServletResponse response) { + User user = authService.login(request); + String accessToken = authRedisService.setTokenUser(user, response); + + Map data = new HashMap<>(); + data.put("accessToken", accessToken); + data.put("user", Map.of( + "userId", user.getUserId(), + "email", user.getUserEmail(), + "nickname", user.getUserNickname(), + "name", user.getUserName() + )); + + return ResponseEntity.ok(SuccessApiResponse.of("로그인 완료!", data)); + } + + /** + * 로그아웃 + * @param authorization Authorization 헤더 + * @param refreshToken 쿠키의 refreshToken + * @param response HttpServletResponse + */ + @PostMapping("/logout") + public ResponseEntity> logout(@RequestHeader(value = "Authorization", required = false) String authorization, + @CookieValue(value = "refreshToken", required = false) String refreshToken, + HttpServletResponse response) { + authRedisService.deleteTokenUser(authorization, refreshToken, response); + + return ResponseEntity.ok(SuccessApiResponse.of("로그아웃이 완료되었습니다.")); + } + + /** + * Access Token 갱신 + * @param refreshToken 쿠키의 refreshToken + */ + @PostMapping("/refresh") + public ResponseEntity>> refreshToken(@CookieValue(value = "refreshToken", required = false) String refreshToken) { + String newAccessToken = authRedisService.refreshAccessToken(refreshToken); + + Map data = new HashMap<>(); + data.put("accessToken", newAccessToken); - return ResponseEntity.ok(ApiResponse.success("로그인 완료!")); + return ResponseEntity.ok(SuccessApiResponse.of("Access Token이 갱신되었습니다.", data)); } /** 회원가입 diff --git a/src/main/java/com/fastcampus/book_bot/service/auth/AuthRedisService.java b/src/main/java/com/fastcampus/book_bot/service/auth/AuthRedisService.java new file mode 100644 index 0000000..262506b --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/service/auth/AuthRedisService.java @@ -0,0 +1,130 @@ +package com.fastcampus.book_bot.service.auth; + +import com.fastcampus.book_bot.common.exception.user.UserDomainException; +import com.fastcampus.book_bot.common.exception.user.UserErrorCode; +import com.fastcampus.book_bot.common.utils.JwtUtil; +import com.fastcampus.book_bot.domain.user.User; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AuthRedisService { + + private final JwtUtil jwtUtil; + private final RedisTemplate redisTemplate; + + /** 로그인 시, JWT 토큰 발급 + * */ + public String setTokenUser(User user, HttpServletResponse response) { + + try { + // Access Token 생성 + String accessToken = jwtUtil.createAccessToken(user.getUserId(), "USER"); + + // Refresh Token 생성 + String refreshToken = jwtUtil.createRefreshToken(user.getUserId()); + + // redis에 Refresh Token 저장 + String redisKey = "refresh_token:" + user.getUserId(); + redisTemplate.opsForValue().set(redisKey, refreshToken, 7, TimeUnit.DAYS); + + Cookie refreshCookie = new Cookie("refreshToken", refreshToken); + refreshCookie.setHttpOnly(true); // XSS 공격 방지 + refreshCookie.setSecure(true); // HTTPS에서만 전송 (운영환경) + refreshCookie.setPath("/"); // 모든 경로에서 접근 가능 + refreshCookie.setMaxAge(7 * 24 * 60 * 60); + + response.addCookie(refreshCookie); + + log.info("로그인 성공 - 사용자 ID: {}", user.getUserId()); + return accessToken; + } catch (Exception e) { + throw UserDomainException.badRequest(UserErrorCode.LOGIN_FAILED.getMessage(), UserErrorCode.LOGIN_FAILED.getCode()); + } + + } + + /** 로그아웃시, JWT 삭제 + * */ + public void deleteTokenUser(String authorization, String refreshToken, HttpServletResponse response) { + try { + // Authorization 헤더에서 Access Token 추출 + String accessToken = null; + if (authorization != null && authorization.startsWith("Bearer ")) { + accessToken = authorization.substring(7); + } + + // Access Token 기본 유효성 검사 후 사용자 ID 추출 + Integer userId = null; + if (accessToken != null) { + try { + if (!jwtUtil.isTokenExpired(accessToken)) { + userId = jwtUtil.extractUserId(accessToken); + } + } catch (Exception e) { + log.warn("Access Token 파싱 실패: {}", e.getMessage()); + } + } + + // Redis에서 Refresh Token 삭제 + if (userId != null) { + String redisKey = "refresh_token:" + userId; + redisTemplate.delete(redisKey); + log.info("Redis에서 사용자 삭제 ID: {}", userId); + } + + // 4. HttpOnly 쿠키에서 Refresh Token 삭제 + if (refreshToken != null) { + Cookie deleteCookie = new Cookie("refreshToken", null); + deleteCookie.setHttpOnly(true); + deleteCookie.setSecure(true); + deleteCookie.setPath("/"); + deleteCookie.setMaxAge(0); // 즉시 만료 + response.addCookie(deleteCookie); + log.info("쿠키에서 Refresh Token 삭제"); + } + } catch (Exception e) { + throw UserDomainException.internalServerError(UserErrorCode.SYSTEM_ERROR.getMessage(), UserErrorCode.SYSTEM_ERROR.getCode()); + } + } + + /** AccessToken 갱신 + * */ + public String refreshAccessToken(String refreshToken) { + try { + if (refreshToken == null) { + throw UserDomainException.badRequest("RefreshToken이 없습니다.", UserErrorCode.INVALID_DATA.getCode()); + } + + if (jwtUtil.isTokenExpired(refreshToken)) { + throw UserDomainException.badRequest("만료된 RefreshToken입니다.", UserErrorCode.INVALID_DATA.getCode()); + } + + Integer userId = jwtUtil.extractUserId(refreshToken); + + String redisKey = "refresh_token:" + userId; + String storedRefreshToken = redisTemplate.opsForValue().get(redisKey); + + if (storedRefreshToken == null || !storedRefreshToken.equals(refreshToken)) { + throw UserDomainException.badRequest("유효하지 않은 Refresh Token입니다.", UserErrorCode.INVALID_DATA.getCode()); + } + + String newAccessToken = jwtUtil.createAccessToken(userId, "USER"); + + log.info("Access Token 갱신 성공 - 사용자 ID: {}", userId); + return newAccessToken; + } catch (UserDomainException e) { + throw e; + } catch (Exception e) { + throw UserDomainException.internalServerError("토큰 갱신 중 오류 발생!", UserErrorCode.SYSTEM_ERROR.getCode()); + } + } +} diff --git a/src/main/resources/templates/auth/login.html b/src/main/resources/templates/auth/login.html index b0f787f..7d91c54 100644 --- a/src/main/resources/templates/auth/login.html +++ b/src/main/resources/templates/auth/login.html @@ -377,7 +377,6 @@

로그인

\ No newline at end of file From c674183915cd2bff7c6edb6c11199bf0e4399cd9 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Sat, 16 Aug 2025 20:41:03 +0900 Subject: [PATCH 20/27] =?UTF-8?q?chore=20:=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/fastcampus/book_bot/controller/auth/AuthController.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java b/src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java index 2f79d5a..07f8e93 100644 --- a/src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java +++ b/src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java @@ -24,7 +24,6 @@ public class AuthController { private final MailService mailService; private final AuthService authService; private final AuthRedisService authRedisService; - private final JwtUtil jwtUtil; /** 로그인 * @param request SignupRequestDTO From c6777c6f783c86090417e5a9fa890d96be97bb06 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Sat, 16 Aug 2025 20:41:32 +0900 Subject: [PATCH 21/27] =?UTF-8?q?chore=20:=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/fastcampus/book_bot/controller/auth/AuthController.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java b/src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java index 07f8e93..5a7b405 100644 --- a/src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java +++ b/src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java @@ -1,7 +1,6 @@ package com.fastcampus.book_bot.controller.auth; import com.fastcampus.book_bot.common.response.SuccessApiResponse; -import com.fastcampus.book_bot.common.utils.JwtUtil; import com.fastcampus.book_bot.domain.user.User; import com.fastcampus.book_bot.dto.user.SignupRequestDTO; import com.fastcampus.book_bot.service.auth.AuthRedisService; From 04dba74956c993f2c8fe4b97bfdbed0cdc5749ee Mon Sep 17 00:00:00 2001 From: JiHoon Date: Mon, 18 Aug 2025 17:15:24 +0900 Subject: [PATCH 22/27] =?UTF-8?q?chore=20:=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fastcampus/book_bot/domain/user/User.java | 3 -- .../db/migration/V6__Create_table_payment.sql | 34 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 src/main/resources/db/migration/V6__Create_table_payment.sql diff --git a/src/main/java/com/fastcampus/book_bot/domain/user/User.java b/src/main/java/com/fastcampus/book_bot/domain/user/User.java index 7d7277a..91e81fc 100644 --- a/src/main/java/com/fastcampus/book_bot/domain/user/User.java +++ b/src/main/java/com/fastcampus/book_bot/domain/user/User.java @@ -1,10 +1,7 @@ package com.fastcampus.book_bot.domain.user; import jakarta.persistence.*; -import jakarta.validation.constraints.Email; import lombok.*; -import org.hibernate.annotations.DynamicInsert; -import org.hibernate.annotations.DynamicUpdate; import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; diff --git a/src/main/resources/db/migration/V6__Create_table_payment.sql b/src/main/resources/db/migration/V6__Create_table_payment.sql new file mode 100644 index 0000000..1889d30 --- /dev/null +++ b/src/main/resources/db/migration/V6__Create_table_payment.sql @@ -0,0 +1,34 @@ +CREATE TABLE `payment` ( + `PAYMENT_ID` INT NOT NULL AUTO_INCREMENT, + `ORDER_ID` INT NOT NULL, + `USER_ID` INT NOT NULL, + + -- PG사 구분 (전략 선택용) + `PG_PROVIDER` VARCHAR(30) NOT NULL + CHECK (`PG_PROVIDER` IN ('IAMPORT', 'TOSS_PAYMENTS')), + + -- 기본 결제 정보 + `PAYMENT_KEY` VARCHAR(200) NULL, + `ORDER_UUID` VARCHAR(100) NOT NULL, + `PAYMENT_AMOUNT` INT NOT NULL, + `PAYMENT_METHOD` VARCHAR(30) NULL, + + -- 결제 상태 + `PAYMENT_STATUS` VARCHAR(30) NOT NULL DEFAULT 'PENDING' + CHECK (`PAYMENT_STATUS` IN ('PENDING', 'SUCCESS', 'FAILED', 'CANCELED')), + + -- PG사별 응답 데이터 + `PG_RESPONSE_DATA` JSON NULL, + + -- 에러 정보 + `ERROR_CODE` VARCHAR(50) NULL, + `ERROR_MESSAGE` VARCHAR(255) NULL, + + -- 타임스탬프 + `CREATED_AT` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + `UPDATED_AT` TIMESTAMP(0) NULL ON UPDATE CURRENT_TIMESTAMP, + + PRIMARY KEY (`PAYMENT_ID`), + FOREIGN KEY (`ORDER_ID`) REFERENCES `orders` (`ORDER_ID`) ON DELETE CASCADE, + FOREIGN KEY (`USER_ID`) REFERENCES `user` (`USER_ID`) ON DELETE CASCADE +); \ No newline at end of file From 42e9fe344c8961c07e67bae8e9572408250e6b97 Mon Sep 17 00:00:00 2001 From: JiHoon Date: Mon, 18 Aug 2025 18:50:38 +0900 Subject: [PATCH 23/27] =?UTF-8?q?update=20:=20=EC=A3=BC=EB=AC=B8,=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=9C=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20Repo=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20fix=20:=20login=20=EA=B4=80=EB=A0=A8=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../book_bot/domain/orders/OrderBook.java | 57 ++++++++ .../book_bot/domain/orders/Orders.java | 105 ++++++++++++++ .../book_bot/domain/payment/Payment.java | 135 ++++++++++++++++++ .../repository/OrderBookRepository.java | 7 + .../book_bot/repository/OrderRepository.java | 8 ++ .../book_bot/service/order/OrderService.java | 18 +++ .../migration/V7__Alter_column_orderbook.sql | 2 + .../migration/V8__Alter_column_orderbook.sql | 2 + src/main/resources/templates/auth/login.html | 61 ++++++-- src/main/resources/templates/book/detail.html | 11 +- 10 files changed, 392 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/fastcampus/book_bot/domain/orders/OrderBook.java create mode 100644 src/main/java/com/fastcampus/book_bot/domain/orders/Orders.java create mode 100644 src/main/java/com/fastcampus/book_bot/domain/payment/Payment.java create mode 100644 src/main/java/com/fastcampus/book_bot/repository/OrderBookRepository.java create mode 100644 src/main/java/com/fastcampus/book_bot/repository/OrderRepository.java create mode 100644 src/main/java/com/fastcampus/book_bot/service/order/OrderService.java create mode 100644 src/main/resources/db/migration/V7__Alter_column_orderbook.sql create mode 100644 src/main/resources/db/migration/V8__Alter_column_orderbook.sql diff --git a/src/main/java/com/fastcampus/book_bot/domain/orders/OrderBook.java b/src/main/java/com/fastcampus/book_bot/domain/orders/OrderBook.java new file mode 100644 index 0000000..ec019ec --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/domain/orders/OrderBook.java @@ -0,0 +1,57 @@ +package com.fastcampus.book_bot.domain.orders; + +import com.fastcampus.book_bot.domain.book.Book; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "order_book") +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class OrderBook { + + /** + * 주문 도서 ID (Primary Key) + * 주문상품을 고유하게 식별하는 자동 증가 값 + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ORDER_BOOK_ID") + private Integer orderBookId; + + /** + * 주문 수량 + * 해당 도서를 몇 권 주문했는지 + */ + @Column(name = "QUANTITY") + private Integer quantity; + + /** + * 주문 당시 가격 + * 주문 시점의 도서 가격을 저장 + * 가격 변동이 있어도 주문 당시 가격 유지 + */ + @Column(name = "PRICE") + private Integer price; + + /** + * 소속 주문 + * N:1 관계 - 여러 주문상품이 한 주문에 속함 + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ORDER_ID", nullable = false) + private Orders order; + + /** + * 주문된 도서 + * N:1 관계 - 여러 주문상품이 같은 도서를 참조할 수 있음 + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "BOOK_ID", nullable = false) + private Book book; +} diff --git a/src/main/java/com/fastcampus/book_bot/domain/orders/Orders.java b/src/main/java/com/fastcampus/book_bot/domain/orders/Orders.java new file mode 100644 index 0000000..88ab0e5 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/domain/orders/Orders.java @@ -0,0 +1,105 @@ +package com.fastcampus.book_bot.domain.orders; + +import com.fastcampus.book_bot.domain.payment.Payment; +import com.fastcampus.book_bot.domain.user.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Table(name = "orders") +@Getter +@Builder +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +@NoArgsConstructor +public class Orders { + + /** + * 주문 ID (Primary Key) + * 시스템 내부에서 주문을 고유하게 식별하는 자동 증가 값 + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ORDER_ID") + private Integer orderId; + + /** + * 주문 상태 + * 주문의 처리 단계를 나타냄 + * 결제와는 별개의 물류/배송 상태 + * 'ORDER_READY', 'ORDER_PROCESSING', 'SHIPPED', 'DELIVERED' + */ + @Column(name = "ORDER_STATUS", nullable = false, length = 30) + private String orderStatus = "ORDER_READY"; + + /** + * 총 주문 금액 + * order_book 테이블의 (PRICE * QUANTITY) 합계 + * 계산된 값이므로 비정규화된 필드 + */ + @Column(name = "TOTAL_PRICE") + private Integer totalPrice; + + /** + * 주문 날짜 (DATE 타입) + * 주문이 생성된 날짜만 저장 (시간 정보 없음) + * 일별 통계나 리포트에서 사용 + */ + @Column(name = "ORDER_DAY") + private LocalDate orderDay; + + /** + * 주문 일시 (TIMESTAMP 타입) + * 주문이 생성된 정확한 시간 + * 상세한 시간 정보가 필요한 경우 사용 + */ + @Column(name = "ORDER_DATE") + private LocalDateTime orderDate; + + /** + * 생성 시간 + * 레코드가 생성된 시간 (자동 설정) + */ + @Column(name = "CREATED_AT", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * 수정 시간 + * 레코드가 마지막으로 수정된 시간 (자동 업데이트) + */ + @Column(name = "UPDATED_AT") + private LocalDateTime updatedAt; + + /** + * 주문한 사용자 정보 + * N:1 관계 - 여러 주문이 한 사용자에 속함 + */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "USER_ID", nullable = false) + private User user; + + /** + * 주문 상품 목록 + * 1:N 관계 - 한 주문에 여러 도서가 포함됨 + * CascadeType.ALL: 주문 삭제 시 주문상품도 함께 삭제 + */ + @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + @Builder.Default + private List orderBooks = new ArrayList<>(); + + /** + * 결제 정보 + * 1:1 관계 - 한 주문에 하나의 결제 + */ + @OneToOne(mappedBy = "order", cascade = CascadeType.ALL) + private Payment payment; +} diff --git a/src/main/java/com/fastcampus/book_bot/domain/payment/Payment.java b/src/main/java/com/fastcampus/book_bot/domain/payment/Payment.java new file mode 100644 index 0000000..72636ce --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/domain/payment/Payment.java @@ -0,0 +1,135 @@ +package com.fastcampus.book_bot.domain.payment; + +import com.fastcampus.book_bot.domain.orders.Orders; +import com.fastcampus.book_bot.domain.user.User; +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "payment") +@Getter +@Builder +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +@NoArgsConstructor +public class Payment { + + /** + * 결제 ID (Primary Key) + * 시스템 내부에서 결제를 고유하게 식별하는 자동 증가 값 + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "PAYMENT_ID") + private Integer paymentId; + + /** + * PG사 구분자 (전략패턴의 핵심) + * 어떤 결제 전략(PG사)을 사용했는지 구분 + * 런타임에 전략 선택을 위한 중요한 필드 + */ + @Column(name = "PG_PROVIDER", nullable = false, length = 30) + private String pgProvider; + + /** + * 결제 고유키 + * 각 PG사에서 발급하는 결제 식별자 + * - 토스페이먼츠: paymentKey + * - 아임포트: imp_uid + * - 카카오페이: tid + */ + @Column(name = "PAYMENT_KEY", length = 200) + private String paymentKey; + + /** + * 주문 고유번호 + * 우리 시스템에서 생성하는 주문 식별자 (보통 UUID) + * PG사에 전달하는 orderId 역할 + * 중복되면 안 되므로 UNIQUE 제약조건 필요 + */ + @Column(name = "ORDER_UUID", nullable = false, length = 100, unique = true) + private String orderUuid; + + /** + * 결제 금액 + * 실제 결제 요청한 금액 (원 단위) + */ + @Column(name = "PAYMENT_AMOUNT", nullable = false) + private Integer paymentAmount; + + /** + * 결제 수단 + * 카드, 계좌이체, 가상계좌 등 + * PG사별로 지원하는 결제수단이 다를 수 있음 + */ + @Column(name = "PAYMENT_METHOD", length = 30) + private String paymentMethod; + + /** + * 결제 상태 + * 전략패턴 실행 결과를 나타내는 중요한 필드 + */ + @Column(name = "PAYMENT_STATUS", nullable = false, length = 30) + private String paymentStatus; + + /** + * PG사별 응답 데이터 + * 각 PG사에서 반환하는 원본 응답을 JSON으로 저장 + * 디버깅 및 추가 정보 확인용 + * - 토스페이먼츠: 카드사 정보, 승인번호 등 + * - 아임포트: PG사별 상세 응답 등 + */ + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "PG_RESPONSE_DATA", columnDefinition = "JSON") + private JsonNode pgResponseData; + + /** + * 에러 코드 + * 결제 실패 시 PG사에서 반환하는 에러 코드 + */ + @Column(name = "ERROR_CODE", length = 50) + private String errorCode; + + /** + * 에러 메시지 + * 결제 실패 시 사용자에게 보여줄 에러 메시지 + */ + @Column(name = "ERROR_MESSAGE", length = 255) + private String errorMessage; + + /** + * 생성 시간 + * 결제 요청이 생성된 시간 + */ + @CreatedDate + @Column(name = "CREATED_AT", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * 수정 시간 + * 결제 상태가 변경된 시간 (마지막 업데이트) + */ + @LastModifiedDate + @Column(name = "UPDATED_AT") + private LocalDateTime updatedAt; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ORDER_ID") + private Orders order; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "USER_ID") + private User user; + +} diff --git a/src/main/java/com/fastcampus/book_bot/repository/OrderBookRepository.java b/src/main/java/com/fastcampus/book_bot/repository/OrderBookRepository.java new file mode 100644 index 0000000..265e1ea --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/repository/OrderBookRepository.java @@ -0,0 +1,7 @@ +package com.fastcampus.book_bot.repository; + +import com.fastcampus.book_bot.domain.orders.OrderBook; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderBookRepository extends JpaRepository { +} diff --git a/src/main/java/com/fastcampus/book_bot/repository/OrderRepository.java b/src/main/java/com/fastcampus/book_bot/repository/OrderRepository.java new file mode 100644 index 0000000..a22e19e --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/repository/OrderRepository.java @@ -0,0 +1,8 @@ +package com.fastcampus.book_bot.repository; + +import com.fastcampus.book_bot.domain.orders.Orders; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderRepository extends JpaRepository { + +} diff --git a/src/main/java/com/fastcampus/book_bot/service/order/OrderService.java b/src/main/java/com/fastcampus/book_bot/service/order/OrderService.java new file mode 100644 index 0000000..8088088 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/service/order/OrderService.java @@ -0,0 +1,18 @@ +package com.fastcampus.book_bot.service.order; + +import com.fastcampus.book_bot.repository.OrderBookRepository; +import com.fastcampus.book_bot.repository.OrderRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@Slf4j +@RequiredArgsConstructor +public class OrderService { + + private final OrderRepository orderRepository; + private final OrderBookRepository orderBookRepository; + + +} diff --git a/src/main/resources/db/migration/V7__Alter_column_orderbook.sql b/src/main/resources/db/migration/V7__Alter_column_orderbook.sql new file mode 100644 index 0000000..833843c --- /dev/null +++ b/src/main/resources/db/migration/V7__Alter_column_orderbook.sql @@ -0,0 +1,2 @@ +ALTER TABLE `order_book` + MODIFY COLUMN order_book_id BIGINT; \ No newline at end of file diff --git a/src/main/resources/db/migration/V8__Alter_column_orderbook.sql b/src/main/resources/db/migration/V8__Alter_column_orderbook.sql new file mode 100644 index 0000000..63bedd7 --- /dev/null +++ b/src/main/resources/db/migration/V8__Alter_column_orderbook.sql @@ -0,0 +1,2 @@ +ALTER TABLE `order_book` + MODIFY COLUMN order_book_id INT; \ No newline at end of file diff --git a/src/main/resources/templates/auth/login.html b/src/main/resources/templates/auth/login.html index 7d91c54..e2c7811 100644 --- a/src/main/resources/templates/auth/login.html +++ b/src/main/resources/templates/auth/login.html @@ -377,6 +377,29 @@

로그인

\ No newline at end of file diff --git a/src/main/resources/templates/book/detail.html b/src/main/resources/templates/book/detail.html index 6cff2f2..0505b8d 100644 --- a/src/main/resources/templates/book/detail.html +++ b/src/main/resources/templates/book/detail.html @@ -3,7 +3,7 @@ - 도서 상세 - 온라인 서점 + 도서 상세 - 온라인 서점 + + +
+

📚 주문하기

+ + + + + +

주문 도서

+
+
+
+ 스프링 부트 완벽 가이드
+ 25,000원 +
+
+ + + +
+
+ +
+
+ 자바 디자인 패턴
+ 18,000원 +
+
+ + + +
+
+
+ + +
+

포인트 사용

+ + +
+ + +
+

주문 요약

+
+ 상품 금액 + 61,000원 +
+
+ 등급 할인 (3%) + -1,830원 +
+
+ 포인트 사용 + 0원 +
+
+ 배송비 + 3,000원 +
+
+ 최종 결제 금액 + 62,170원 +
+
+ 적립 예정 마일리지: 1,775P +
+
+ + +
+ + + + \ No newline at end of file From 6b26a8f3fca39ab613f5940dd335d4e1875be0cc Mon Sep 17 00:00:00 2001 From: JiHoon Date: Wed, 20 Aug 2025 15:26:42 +0900 Subject: [PATCH 26/27] =?UTF-8?q?update=20:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20=EC=9D=B4=EB=A0=A5=20=EC=B6=94=EC=A0=81?= =?UTF-8?q?=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mysql-docker/docker-compose.yml | 1 + .../db/migration/V10__Create_table_log.sql | 23 +++++ .../db/migration/V11__Create_trigger_log.sql | 91 +++++++++++++++++++ .../migration/V12__Alter_trigger_userlog.sql | 29 ++++++ .../db/migration/V13__Alter_table_log.sql | 3 + 5 files changed, 147 insertions(+) create mode 100644 src/main/resources/db/migration/V10__Create_table_log.sql create mode 100644 src/main/resources/db/migration/V11__Create_trigger_log.sql create mode 100644 src/main/resources/db/migration/V12__Alter_trigger_userlog.sql create mode 100644 src/main/resources/db/migration/V13__Alter_table_log.sql diff --git a/mysql-docker/docker-compose.yml b/mysql-docker/docker-compose.yml index 1010eae..0401ecd 100644 --- a/mysql-docker/docker-compose.yml +++ b/mysql-docker/docker-compose.yml @@ -15,6 +15,7 @@ services: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --lower-case-table-names=0 + --log-bin-trust-function-creators=1 volumes: - mysql-data:/var/lib/mysql networks: diff --git a/src/main/resources/db/migration/V10__Create_table_log.sql b/src/main/resources/db/migration/V10__Create_table_log.sql new file mode 100644 index 0000000..19b31cb --- /dev/null +++ b/src/main/resources/db/migration/V10__Create_table_log.sql @@ -0,0 +1,23 @@ +CREATE TABLE `user_log` ( + `USER_LOG` INT NOT NULL AUTO_INCREMENT, + `USER_ID` INT NOT NULL, + `ACTION_TYPE` VARCHAR(30) NULL, + `BEFORE_DATA` JSON NULL, + `AFTER_DATA` JSON NULL, + `CREATED_AT` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`USER_LOG`), + FOREIGN KEY (`USER_ID`) REFERENCES `user` (`USER_ID`) +); + +CREATE TABLE `book_log` ( + `BOOK_LOG` INT NOT NULL AUTO_INCREMENT, + `BOOK_ID` INT NOT NULL, + `ACTION_TYPE` VARCHAR(30) NULL, + `BEFORE_DATA` JSON NULL, + `AFTER_DATA` JSON NULL, + `CREATED_AT` TIMESTAMP(0) NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`BOOK_LOG`), + FOREIGN KEY (`BOOK_ID`) REFERENCES `books` (`BOOK_ID`) +); + +DROP TABLE `manage_log`; \ No newline at end of file diff --git a/src/main/resources/db/migration/V11__Create_trigger_log.sql b/src/main/resources/db/migration/V11__Create_trigger_log.sql new file mode 100644 index 0000000..f662c51 --- /dev/null +++ b/src/main/resources/db/migration/V11__Create_trigger_log.sql @@ -0,0 +1,91 @@ +DELIMITER $$ +CREATE TRIGGER user_insert_log + AFTER INSERT ON user + FOR EACH ROW +BEGIN + INSERT INTO `user_log` (`USER_ID`, `ACTION_TYPE`, `BEFORE_DATA`, `AFTER_DATA`) + VALUES (NEW.USER_ID, + 'INSERT', + NULL, + JSON_OBJECT( + 'USER_ID', NEW.USER_ID, + 'GRADE_ID', NEW.GRADE_ID, + 'USER_EMAIL', NEW.USER_EMAIL, + 'USER_NICKNAME', NEW.USER_NICKNAME, + 'USER_NAME', NEW.USER_NAME, + 'USER_PHONE', NEW.USER_PHONE, + 'USER_STATUS', NEW.USER_STATUS, + 'POINT', NEW.POINT, + 'POSTCODE', NEW.POSTCODE, + 'DEFAULT_ADDRESS', NEW.DEFAULT_ADDRESS, + 'DETAIL_ADDRESS', NEW.DETAIL_ADDRESS, + 'CITY', NEW.CITY, + 'PROVINCE', NEW.PROVINCE + )); +END$$ + +CREATE TRIGGER user_update_log + AFTER UPDATE ON user + FOR EACH ROW +BEGIN + INSERT INTO `user_log` (`USER_ID`, `ACTION_TYPE`, `BEFORE_DATA`, `AFTER_DATA`) + VALUES (NEW.USER_ID, + 'UPDATE', + JSON_OBJECT( + 'USER_ID', OLD.USER_ID, + 'GRADE_ID', OLD.GRADE_ID, + 'USER_EMAIL', OLD.USER_EMAIL, + 'USER_NICKNAME', OLD.USER_NICKNAME, + 'USER_NAME', OLD.USER_NAME, + 'USER_PHONE', OLD.USER_PHONE, + 'USER_STATUS', OLD.USER_STATUS, + 'POINT', OLD.POINT, + 'POSTCODE', OLD.POSTCODE, + 'DEFAULT_ADDRESS', OLD.DEFAULT_ADDRESS, + 'DETAIL_ADDRESS', OLD.DETAIL_ADDRESS, + 'CITY', OLD.CITY, + 'PROVINCE', OLD.PROVINCE + ), + JSON_OBJECT( + 'USER_ID', NEW.USER_ID, + 'GRADE_ID', NEW.GRADE_ID, + 'USER_EMAIL', NEW.USER_EMAIL, + 'USER_NICKNAME', NEW.USER_NICKNAME, + 'USER_NAME', NEW.USER_NAME, + 'USER_PHONE', NEW.USER_PHONE, + 'USER_STATUS', NEW.USER_STATUS, + 'POINT', NEW.POINT, + 'POSTCODE', NEW.POSTCODE, + 'DEFAULT_ADDRESS', NEW.DEFAULT_ADDRESS, + 'DETAIL_ADDRESS', NEW.DETAIL_ADDRESS, + 'CITY', NEW.CITY, + 'PROVINCE', NEW.PROVINCE + )); +END$$ + +CREATE TRIGGER user_delete_log + AFTER DELETE ON user + FOR EACH ROW +BEGIN + INSERT INTO `user_log` (`USER_ID`, `ACTION_TYPE`, `BEFORE_DATA`, `AFTER_DATA`) + VALUES (OLD.USER_ID, + 'DELETE', + JSON_OBJECT( + 'USER_ID', OLD.USER_ID, + 'GRADE_ID', OLD.GRADE_ID, + 'USER_EMAIL', OLD.USER_EMAIL, + 'USER_NICKNAME', OLD.USER_NICKNAME, + 'USER_NAME', OLD.USER_NAME, + 'USER_PHONE', OLD.USER_PHONE, + 'USER_STATUS', OLD.USER_STATUS, + 'POINT', OLD.POINT, + 'POSTCODE', OLD.POSTCODE, + 'DEFAULT_ADDRESS', OLD.DEFAULT_ADDRESS, + 'DETAIL_ADDRESS', OLD.DETAIL_ADDRESS, + 'CITY', OLD.CITY, + 'PROVINCE', OLD.PROVINCE + ), + NULL); +END$$ + +DELIMITER ; \ No newline at end of file diff --git a/src/main/resources/db/migration/V12__Alter_trigger_userlog.sql b/src/main/resources/db/migration/V12__Alter_trigger_userlog.sql new file mode 100644 index 0000000..ed8ae9b --- /dev/null +++ b/src/main/resources/db/migration/V12__Alter_trigger_userlog.sql @@ -0,0 +1,29 @@ +DROP TRIGGER IF EXISTS user_delete_log; + +DELIMITER $$ +CREATE TRIGGER user_delete_log + BEFORE DELETE ON user + FOR EACH ROW +BEGIN + INSERT INTO `user_log` (`USER_ID`, `ACTION_TYPE`, `BEFORE_DATA`, `AFTER_DATA`) + VALUES (OLD.USER_ID, + 'DELETE', + JSON_OBJECT( + 'USER_ID', OLD.USER_ID, + 'GRADE_ID', OLD.GRADE_ID, + 'USER_EMAIL', OLD.USER_EMAIL, + 'USER_NICKNAME', OLD.USER_NICKNAME, + 'USER_NAME', OLD.USER_NAME, + 'USER_PHONE', OLD.USER_PHONE, + 'USER_STATUS', OLD.USER_STATUS, + 'POINT', OLD.POINT, + 'POSTCODE', OLD.POSTCODE, + 'DEFAULT_ADDRESS', OLD.DEFAULT_ADDRESS, + 'DETAIL_ADDRESS', OLD.DETAIL_ADDRESS, + 'CITY', OLD.CITY, + 'PROVINCE', OLD.PROVINCE + ), + NULL); +END$$ + +DELIMITER ; \ No newline at end of file diff --git a/src/main/resources/db/migration/V13__Alter_table_log.sql b/src/main/resources/db/migration/V13__Alter_table_log.sql new file mode 100644 index 0000000..7d5590c --- /dev/null +++ b/src/main/resources/db/migration/V13__Alter_table_log.sql @@ -0,0 +1,3 @@ +ALTER TABLE `user_log` DROP FOREIGN KEY `user_log_ibfk_1`; + +ALTER TABLE `book_log` DROP FOREIGN KEY `book_log_ibfk_1`; \ No newline at end of file From a2fa17a2110d7b7940151a96386fab5a0eced58e Mon Sep 17 00:00:00 2001 From: JiHoon Date: Sat, 30 Aug 2025 20:04:20 +0900 Subject: [PATCH 27/27] =?UTF-8?q?feat=20:=20=EC=A3=BC=EB=AC=B8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=A3=BC=EB=AC=B8?= =?UTF-8?q?=20=EB=AA=A8=EB=8D=B8=EB=A7=81=20=EC=A0=90=EA=B2=80=20=EC=A4=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 + .../controller/order/OrderController.java | 26 + .../controller/order/OrderViewController.java | 2 +- .../service/auth/AuthRedisService.java | 2 +- .../book_bot/service/order/OrderService.java | 9 + src/main/resources/templates/book/detail.html | 1 - src/main/resources/templates/order/order.html | 569 +++++++++++++----- 7 files changed, 456 insertions(+), 155 deletions(-) create mode 100644 src/main/java/com/fastcampus/book_bot/controller/order/OrderController.java diff --git a/build.gradle b/build.gradle index 4c541c4..07a29b7 100644 --- a/build.gradle +++ b/build.gradle @@ -55,6 +55,8 @@ dependencies { // JUnit 플랫폼 런처 (테스트 실행) testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + implementation 'io.netty:netty-resolver-dns-native-macos:4.1.108.Final:osx-aarch_64' + // JWT 토큰 생성/파싱/검증 API implementation 'io.jsonwebtoken:jjwt-api:0.11.5' // JWT 구현체 diff --git a/src/main/java/com/fastcampus/book_bot/controller/order/OrderController.java b/src/main/java/com/fastcampus/book_bot/controller/order/OrderController.java new file mode 100644 index 0000000..4fddbfe --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/controller/order/OrderController.java @@ -0,0 +1,26 @@ +package com.fastcampus.book_bot.controller.order; + +import com.fastcampus.book_bot.common.response.SuccessApiResponse; +import com.fastcampus.book_bot.dto.order.OrdersDTO; +import com.fastcampus.book_bot.service.order.OrderService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/order") +@RequiredArgsConstructor +public class OrderController { + + private final OrderService orderService; + + @PostMapping("/complete") + public ResponseEntity> orderComplete(OrdersDTO ordersDTO) { + + orderService.orderBook(ordersDTO); + + return ResponseEntity.ok(SuccessApiResponse.of("")); + } +} diff --git a/src/main/java/com/fastcampus/book_bot/controller/order/OrderViewController.java b/src/main/java/com/fastcampus/book_bot/controller/order/OrderViewController.java index e4e4aa6..d84da5e 100644 --- a/src/main/java/com/fastcampus/book_bot/controller/order/OrderViewController.java +++ b/src/main/java/com/fastcampus/book_bot/controller/order/OrderViewController.java @@ -9,7 +9,7 @@ public class OrderViewController { @PostMapping("/order") - public String order(@ModelAttribute OrdersDTO ordersDTO) { + public String order(@ModelAttribute("orderForm") OrdersDTO ordersDTO) { return "order/order"; } diff --git a/src/main/java/com/fastcampus/book_bot/service/auth/AuthRedisService.java b/src/main/java/com/fastcampus/book_bot/service/auth/AuthRedisService.java index 262506b..6be852b 100644 --- a/src/main/java/com/fastcampus/book_bot/service/auth/AuthRedisService.java +++ b/src/main/java/com/fastcampus/book_bot/service/auth/AuthRedisService.java @@ -38,7 +38,7 @@ public String setTokenUser(User user, HttpServletResponse response) { Cookie refreshCookie = new Cookie("refreshToken", refreshToken); refreshCookie.setHttpOnly(true); // XSS 공격 방지 - refreshCookie.setSecure(true); // HTTPS에서만 전송 (운영환경) + refreshCookie.setSecure(false); // HTTP에서만 전송 refreshCookie.setPath("/"); // 모든 경로에서 접근 가능 refreshCookie.setMaxAge(7 * 24 * 60 * 60); diff --git a/src/main/java/com/fastcampus/book_bot/service/order/OrderService.java b/src/main/java/com/fastcampus/book_bot/service/order/OrderService.java index 8088088..fc0291d 100644 --- a/src/main/java/com/fastcampus/book_bot/service/order/OrderService.java +++ b/src/main/java/com/fastcampus/book_bot/service/order/OrderService.java @@ -1,10 +1,12 @@ package com.fastcampus.book_bot.service.order; +import com.fastcampus.book_bot.dto.order.OrdersDTO; import com.fastcampus.book_bot.repository.OrderBookRepository; import com.fastcampus.book_bot.repository.OrderRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @Slf4j @@ -15,4 +17,11 @@ public class OrderService { private final OrderBookRepository orderBookRepository; + @Transactional + public void orderBook(OrdersDTO ordersDTO) { + + /* ordersDTO to order Entity */ + + } + } diff --git a/src/main/resources/templates/book/detail.html b/src/main/resources/templates/book/detail.html index 966d520..53c8c38 100644 --- a/src/main/resources/templates/book/detail.html +++ b/src/main/resources/templates/book/detail.html @@ -260,7 +260,6 @@

도서 제목

- diff --git a/src/main/resources/templates/order/order.html b/src/main/resources/templates/order/order.html index f539d23..b2c89db 100644 --- a/src/main/resources/templates/order/order.html +++ b/src/main/resources/templates/order/order.html @@ -1,314 +1,579 @@ - + - 주문하기 + 주문하기 - 온라인 서점 + +
-

📚 주문하기

+ + + +

+ 주문하기 +