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..ae5c741 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +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/ + +### application.yml ### +src/main/resources/application.yml diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..07a29b7 --- /dev/null +++ b/build.gradle @@ -0,0 +1,70 @@ +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 { + // 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' + + 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 구현체 + implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' + // JWT JSON 처리(Jackson 연동) + implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\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/mysql-docker/docker-compose.yml b/mysql-docker/docker-compose.yml new file mode 100644 index 0000000..0401ecd --- /dev/null +++ b/mysql-docker/docker-compose.yml @@ -0,0 +1,67 @@ +services: + mysql: + image: mysql:8.0 + container_name: bookshop-mysql + restart: always + ports: + - "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 + --log-bin-trust-function-creators=1 + volumes: + - mysql-data:/var/lib/mysql + networks: + - 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: + bookshop-network: + driver: bridge \ No newline at end of file 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..1c00d83 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/BookBotApplication.java @@ -0,0 +1,15 @@ +package com.fastcampus.book_bot; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@EnableJpaAuditing +@SpringBootApplication +public class BookBotApplication { + + public static void main(String[] args) { + SpringApplication.run(BookBotApplication.class, args); + } + +} 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..bf826d4 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/common/advice/GlobalExceptionHandler.java @@ -0,0 +1,39 @@ +package com.fastcampus.book_bot.common.advice; + +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.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +@Slf4j +public class GlobalExceptionHandler { + + /** 도메인 에러 핸들러 + * @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/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/common/config/WebClientConfig.java b/src/main/java/com/fastcampus/book_bot/common/config/WebClientConfig.java new file mode 100644 index 0000000..8d3e509 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/common/config/WebClientConfig.java @@ -0,0 +1,46 @@ +package com.fastcampus.book_bot.common.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/common/exception/BaseDomainException.java b/src/main/java/com/fastcampus/book_bot/common/exception/BaseDomainException.java new file mode 100644 index 0000000..ebf13a7 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/common/exception/BaseDomainException.java @@ -0,0 +1,33 @@ +package com.fastcampus.book_bot.common.exception; + +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) { + super(message); + 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 new file mode 100644 index 0000000..411b54d --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/common/exception/user/UserDomainException.java @@ -0,0 +1,95 @@ +package com.fastcampus.book_bot.common.exception.user; + +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 UserDomainException(String message, String errorCode, + HttpStatus httpStatus, Map errorDetails) { + super(message, errorCode, "USER", httpStatus, errorDetails); + } + + /** + * 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); + } + + /** + * UNPROCESSABLE_ENTITY (422) 예외 생성 + */ + public static UserDomainException unprocessableEntity(String message, String errorCode) { + return new UserDomainException(message, errorCode, HttpStatus.UNPROCESSABLE_ENTITY); + } + + public static UserDomainException unprocessableEntity(String message, String errorCode, Map errorDetails) { + return new UserDomainException(message, errorCode, HttpStatus.UNPROCESSABLE_ENTITY, errorDetails); + } + + /** + * INTERNAL_SERVER_ERROR (500) 예외 생성 + */ + public static UserDomainException internalServerError(String message, String errorCode) { + return new UserDomainException(message, errorCode, HttpStatus.INTERNAL_SERVER_ERROR); + } + + 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/ApiResponse.java b/src/main/java/com/fastcampus/book_bot/common/response/ApiResponse.java new file mode 100644 index 0000000..78d1dfd --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/common/response/ApiResponse.java @@ -0,0 +1,61 @@ +package com.fastcampus.book_bot.common.response; + +import lombok.Builder; +import lombok.Getter; + +/** ApiResponse 클래스는 API 응답의 기본 구조를 정의 + * 성공 여부, 메시지, 데이터, 에러 코드 생성 + * 성공 응답과 에러 응답을 생성하는 정적 메서드를 제공 + * @param 응답 데이터 + * */ +@Getter +@Builder +public class ApiResponse { + + private Boolean success; + private String message; + 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() + .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/common/response/BaseApiResponse.java b/src/main/java/com/fastcampus/book_bot/common/response/BaseApiResponse.java new file mode 100644 index 0000000..fb901fc --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/common/response/BaseApiResponse.java @@ -0,0 +1,22 @@ +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; + +/** + * API 응답의 기본 구조를 제공하는 추상 클래스 + * 성공 여부, 메시지, 응답 시각 포함 + */ +@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..6044e3f --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/common/response/ErrorApiResponse.java @@ -0,0 +1,39 @@ +package com.fastcampus.book_bot.common.response; + +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 에러에 대한 API 응답을 나타내는 클래스 + */ +@Getter +public class ErrorApiResponse extends BaseApiResponse { + + private final String errorCode; + private final Map 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 of(String message, String errorCode) { + return new ErrorApiResponse(message, errorCode, null); + } + + /** + * @param message 응답 메시지 + * @param errorCode 에러 코드 + * @param errorDetail 상세한 에러 정보 (key-value 형태) + */ + 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 new file mode 100644 index 0000000..2a9354f --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/common/response/SuccessApiResponse.java @@ -0,0 +1,38 @@ +package com.fastcampus.book_bot.common.response; + +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * 성공적인 API 응답을 나타내는 클래스 + * + * @param 응답 데이터의 타입 + */ +@Getter +public class SuccessApiResponse extends BaseApiResponse { + + 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(String message) { + return new SuccessApiResponse<>(message); + } + + /** + * @param data 응답 데이터 + * @param message 응답 메시지 + */ + public static SuccessApiResponse of(String message, T data) { + return new SuccessApiResponse<>(message, data); + } + +} \ No newline at end of file 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/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/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/api/NaverBookApiController.java b/src/main/java/com/fastcampus/book_bot/controller/api/NaverBookApiController.java new file mode 100644 index 0000000..7012df2 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/controller/api/NaverBookApiController.java @@ -0,0 +1,38 @@ +package com.fastcampus.book_bot.controller.api; + +import com.fastcampus.book_bot.common.response.ApiResponse; +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.*; + +@RestController +@RequestMapping("/api/books") +@Slf4j +public class NaverBookApiController { + + private final ApiToMySQLService apiToMySQLService; + + public NaverBookApiController(ApiToMySQLService apiToMySQLService) { + this.apiToMySQLService = apiToMySQLService; + } + + @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); + + ApiResponse apiResponse = apiToMySQLService.searchAndSaveBooks(query, start, display); + + if (apiResponse.getSuccess()) { + return ResponseEntity.ok(apiResponse); + } else { + return ResponseEntity.badRequest().body(apiResponse); + } + + } +} 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..9886cac --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/controller/auth/AuthController.java @@ -0,0 +1,120 @@ +package com.fastcampus.book_bot.controller.auth; + +import com.fastcampus.book_bot.common.response.SuccessApiResponse; +import com.fastcampus.book_bot.domain.user.User; +import com.fastcampus.book_bot.dto.user.SignupRequestDTO; +import com.fastcampus.book_bot.dto.user.UserDTO; +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 +public class AuthController { + + private final MailService mailService; + private final AuthService authService; + private final AuthRedisService authRedisService; + + /** 로그인 + * @param request SignupRequestDTO + * @param response 쿠키 저장 + */ + @PostMapping("/login") + public ResponseEntity>> login(@RequestBody SignupRequestDTO request, + HttpServletResponse response) { + User user = authService.login(request); + String accessToken = authRedisService.setTokenUser(user, response); + + UserDTO userDTO = new UserDTO(user); + + Map data = new HashMap<>(); + data.put("accessToken", accessToken); + data.put("user", userDTO); + + 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(SuccessApiResponse.of("Access Token이 갱신되었습니다.", data)); + } + + /** 회원가입 + * @param request 회원가입 DTO + */ + @PostMapping("/signup") + public ResponseEntity> signup(@RequestBody SignupRequestDTO request) { + authService.signup(request); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(SuccessApiResponse.of("회원가입이 완료되었습니다.")); + } + + /* 닉네임 중복 */ + @GetMapping("/check-nickname") + public ResponseEntity> checkNickname(@RequestParam String nickname) { + boolean response = authService.isDuplicateNickname(nickname); + + String message = response ? "이미 사용 중인 이메일입니다" : "사용 가능한 이메일입니다"; + return ResponseEntity.ok(SuccessApiResponse.of(message)); + } + + /* 이메일 중복 */ + @GetMapping("/check-email") + public ResponseEntity> checkEmail(@RequestParam String userEmail) { + boolean response = authService.isDuplicateEmail(userEmail); + + String message = response ? "이미 사용 중인 닉네임입니다" : "사용 가능한 닉네임입니다"; + return ResponseEntity.ok(SuccessApiResponse.of(message)); + } + + /* 이메일 전송 */ + @PostMapping("/send-verification") + public ResponseEntity> sendEmail(@RequestParam String userEmail) { + mailService.sendVerificationCode(userEmail); + + return ResponseEntity.ok(SuccessApiResponse.of("인증코드가 전송되었습니다.")); + } + + /* 이메일 검증 */ + @PostMapping("/verify-email") + public ResponseEntity> verifyEmail(@RequestBody SignupRequestDTO signupRequest) { + mailService.verifyCode(signupRequest.getEmail(), signupRequest.getVerificationCode()); + + return ResponseEntity.ok(SuccessApiResponse.of("이메일 검증 성공")); + } +} 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 new file mode 100644 index 0000000..c24ab04 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/controller/book/BookController.java @@ -0,0 +1,59 @@ +package com.fastcampus.book_bot.controller.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; +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; +import org.springframework.web.bind.annotation.PathVariable; + +import java.util.Optional; + +@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"; + } + + @GetMapping("/book/{bookId}") + public String bookDetail(@PathVariable Integer 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/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 new file mode 100644 index 0000000..d84da5e --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/controller/order/OrderViewController.java @@ -0,0 +1,16 @@ +package com.fastcampus.book_bot.controller.order; + +import com.fastcampus.book_bot.dto.order.OrdersDTO; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; + +@Controller +public class OrderViewController { + + @PostMapping("/order") + public String order(@ModelAttribute("orderForm") OrdersDTO ordersDTO) { + return "order/order"; + } + +} 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 new file mode 100644 index 0000000..57c0071 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/domain/book/Book.java @@ -0,0 +1,71 @@ +package com.fastcampus.book_bot.domain.book; + +import jakarta.persistence.*; +import jakarta.persistence.Id; +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.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Table(name = "books") +@EntityListeners(AuditingEntityListener.class) +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class Book { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "BOOK_ID", nullable = false, updatable = false) + private Integer bookId; + + @Column(name = "BOOK_NAME", length = 300) + private String bookName; + + @Column(name = "BOOK_AUTHOR", length = 300) + private String bookAuthor; + + @Column(name = "BOOK_PUBLISHER", length = 300) + private String bookPublisher; + + @Column(name = "BOOK_DESCRIPTION", length = 3000) + private String bookDescription; + + @Column(name = "BOOK_PUBDATE") + private LocalDate bookPubdate; + + @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; + + @LastModifiedDate + @Column(name = "UPDATED_AT") + private LocalDateTime updatedAt; + +} 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/domain/user/User.java b/src/main/java/com/fastcampus/book_bot/domain/user/User.java new file mode 100644 index 0000000..36fba16 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/domain/user/User.java @@ -0,0 +1,77 @@ +package com.fastcampus.book_bot.domain.user; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.persistence.*; +import lombok.*; +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") +@EntityListeners(AuditingEntityListener.class) +@Builder +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "USER_ID", nullable = false, updatable = false) + private Integer userId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "GRADE_ID", nullable = false, insertable = false) + private UserGrade userGrade; + + @Column(name = "USER_EMAIL", length = 30) + private String userEmail; + + @Column(name = "USER_PASSWORD", length = 30) + private String userPassword; + + @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; + + @Column(name = "POINT", insertable = false) + 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; + + @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 new file mode 100644 index 0000000..85ca207 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/domain/user/UserGrade.java @@ -0,0 +1,56 @@ +package com.fastcampus.book_bot.domain.user; + +import jakarta.persistence.*; +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.math.BigDecimal; +import java.time.LocalDateTime; + +@Entity +@Table(name = "user_grade") +@EntityListeners(AuditingEntityListener.class) +@Data +@Builder +@AllArgsConstructor +@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; + + @Column(name = "MIN_USAGE") + private Integer minUsage; + + @Column(name = "ORDER_COUNT") + private Integer orderCount; + + @Column(name = "DISCOUNT") + private BigDecimal discount; + + @Column(name = "MILEAGE_RATE") + private BigDecimal mileageRate; + + @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; +} \ No newline at end of file diff --git a/src/main/java/com/fastcampus/book_bot/domain/user/strategy/BronzeGradeStrategy.java b/src/main/java/com/fastcampus/book_bot/domain/user/strategy/BronzeGradeStrategy.java new file mode 100644 index 0000000..3efdf05 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/domain/user/strategy/BronzeGradeStrategy.java @@ -0,0 +1,29 @@ +package com.fastcampus.book_bot.domain.user.strategy; + +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; + +@Component +public class BronzeGradeStrategy implements GradeStrategy { + + @Override + public BigDecimal calculateDiscount(BigDecimal orderAmount) { + return orderAmount.multiply(BigDecimal.valueOf(0)); + } + + @Override + public Integer calculateMileage(BigDecimal orderAmount) { + return orderAmount.multiply(BigDecimal.valueOf(0)).intValue(); + } + + @Override + public boolean canFreeShipping() { + return false; + } + + @Override + public String getGradeName() { + return "Bronze"; + } +} diff --git a/src/main/java/com/fastcampus/book_bot/domain/user/strategy/GoldGradeStrategy.java b/src/main/java/com/fastcampus/book_bot/domain/user/strategy/GoldGradeStrategy.java new file mode 100644 index 0000000..fc3c4bb --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/domain/user/strategy/GoldGradeStrategy.java @@ -0,0 +1,29 @@ +package com.fastcampus.book_bot.domain.user.strategy; + +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; + +@Component +public class GoldGradeStrategy implements GradeStrategy { + + @Override + public BigDecimal calculateDiscount(BigDecimal orderAmount) { + return orderAmount.multiply(BigDecimal.valueOf(0.05)); + } + + @Override + public Integer calculateMileage(BigDecimal orderAmount) { + return orderAmount.multiply(BigDecimal.valueOf(0.05)).intValue(); + } + + @Override + public boolean canFreeShipping() { + return true; + } + + @Override + public String getGradeName() { + return "GOLD"; + } +} diff --git a/src/main/java/com/fastcampus/book_bot/domain/user/strategy/GradeStrategy.java b/src/main/java/com/fastcampus/book_bot/domain/user/strategy/GradeStrategy.java new file mode 100644 index 0000000..2887a12 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/domain/user/strategy/GradeStrategy.java @@ -0,0 +1,10 @@ +package com.fastcampus.book_bot.domain.user.strategy; + +import java.math.BigDecimal; + +public interface GradeStrategy { + BigDecimal calculateDiscount(BigDecimal orderAmount); + Integer calculateMileage(BigDecimal orderAmount); + boolean canFreeShipping(); + String getGradeName(); +} diff --git a/src/main/java/com/fastcampus/book_bot/domain/user/strategy/GradeStrategyFactory.java b/src/main/java/com/fastcampus/book_bot/domain/user/strategy/GradeStrategyFactory.java new file mode 100644 index 0000000..4b1e995 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/domain/user/strategy/GradeStrategyFactory.java @@ -0,0 +1,34 @@ +package com.fastcampus.book_bot.domain.user.strategy; + +import com.fastcampus.book_bot.domain.user.UserGrade; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Component +public class GradeStrategyFactory { + + private final Map strategies; + + public GradeStrategyFactory(List strategyList) { + this.strategies = strategyList.stream() + .collect(Collectors.toMap( + GradeStrategy::getGradeName, + strategy -> strategy + )); + } + + public GradeStrategy getStrategy(String gradeName) { + GradeStrategy strategy = strategies.get(gradeName.toUpperCase()); + if (strategy == null) { + throw new IllegalArgumentException("지원하지 않는 등급입니다." + gradeName); + } + return strategy; + } + + public GradeStrategy getStrategy(UserGrade userGrade) { + return getStrategy(userGrade.getGradeName()); + } +} diff --git a/src/main/java/com/fastcampus/book_bot/domain/user/strategy/PlatinumGradeStrategy.java b/src/main/java/com/fastcampus/book_bot/domain/user/strategy/PlatinumGradeStrategy.java new file mode 100644 index 0000000..46c65a2 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/domain/user/strategy/PlatinumGradeStrategy.java @@ -0,0 +1,29 @@ +package com.fastcampus.book_bot.domain.user.strategy; + +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; + +@Component +public class PlatinumGradeStrategy implements GradeStrategy { + + @Override + public BigDecimal calculateDiscount(BigDecimal orderAmount) { + return orderAmount.multiply(BigDecimal.valueOf(0.08)); + } + + @Override + public Integer calculateMileage(BigDecimal orderAmount) { + return orderAmount.multiply(BigDecimal.valueOf(0.08)).intValue(); + } + + @Override + public boolean canFreeShipping() { + return true; + } + + @Override + public String getGradeName() { + return "PLATINUM"; + } +} diff --git a/src/main/java/com/fastcampus/book_bot/domain/user/strategy/SilverGradeStrategy.java b/src/main/java/com/fastcampus/book_bot/domain/user/strategy/SilverGradeStrategy.java new file mode 100644 index 0000000..21a63e3 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/domain/user/strategy/SilverGradeStrategy.java @@ -0,0 +1,29 @@ +package com.fastcampus.book_bot.domain.user.strategy; + +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; + +@Component +public class SilverGradeStrategy implements GradeStrategy { + + @Override + public BigDecimal calculateDiscount(BigDecimal orderAmount) { + return orderAmount.multiply(BigDecimal.valueOf(0.03)); + } + + @Override + public Integer calculateMileage(BigDecimal orderAmount) { + return orderAmount.multiply(BigDecimal.valueOf(0.03)).intValue(); + } + + @Override + public boolean canFreeShipping() { + return false; + } + + @Override + public String getGradeName() { + return "SILVER"; + } +} 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..eb7338a --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/dto/api/BookDTO.java @@ -0,0 +1,22 @@ +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 new file mode 100644 index 0000000..88d88af --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/dto/api/NaverBookResponseDTO.java @@ -0,0 +1,14 @@ +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 BookDTO[] items; +} 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..ab29e82 --- /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.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/dto/order/OrdersDTO.java b/src/main/java/com/fastcampus/book_bot/dto/order/OrdersDTO.java new file mode 100644 index 0000000..26c81ca --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/dto/order/OrdersDTO.java @@ -0,0 +1,16 @@ +package com.fastcampus.book_bot.dto.order; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class OrdersDTO { + + private Long bookId; + private String bookName; + private String bookAuthor; + private String bookPublisher; + private Integer price; + private Integer quantity; +} 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..d19a89c --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/dto/user/SignupRequestDTO.java @@ -0,0 +1,46 @@ +package com.fastcampus.book_bot.dto.user; + +import jakarta.validation.constraints.*; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; + +@Getter +@Builder +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; + + /* 이메일 검증 필드 */ + private String verificationCode; + + /* 로그인 검증 필드 */ + private boolean rememberMe; +} diff --git a/src/main/java/com/fastcampus/book_bot/dto/user/UserDTO.java b/src/main/java/com/fastcampus/book_bot/dto/user/UserDTO.java new file mode 100644 index 0000000..7853efa --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/dto/user/UserDTO.java @@ -0,0 +1,46 @@ +package com.fastcampus.book_bot.dto.user; + +import com.fastcampus.book_bot.domain.user.User; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserDTO { + + private Integer userId; + private UserGradeDTO userGradeDTO; + private String userEmail; + private String userName; + private String userNickname; + private String userPhone; + private Integer point; + private Integer postcode; + private String defaultAddress; + private String detailAddress; + private String city; + private String province; + + public UserDTO(User user) { + this.userId = user.getUserId(); + this.userEmail = user.getUserEmail(); + this.userName = user.getUserName(); + this.userNickname = user.getUserNickname(); + this.userPhone = user.getUserPhone(); + this.point = user.getPoint(); + this.postcode = user.getPostcode(); + this.defaultAddress = user.getDefaultAddress(); + this.detailAddress = user.getDetailAddress(); + this.city = user.getCity(); + this.province = user.getProvince(); + + // UserGrade를 UserGradeDTO로 변환 + if (user.getUserGrade() != null) { + this.userGradeDTO = new UserGradeDTO(user.getUserGrade()); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/fastcampus/book_bot/dto/user/UserGradeDTO.java b/src/main/java/com/fastcampus/book_bot/dto/user/UserGradeDTO.java new file mode 100644 index 0000000..24634af --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/dto/user/UserGradeDTO.java @@ -0,0 +1,34 @@ +package com.fastcampus.book_bot.dto.user; + +import com.fastcampus.book_bot.domain.user.UserGrade; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class UserGradeDTO { + + private Integer gradeId; + private String gradeName; + private BigDecimal discount; + private BigDecimal mileageRate; + private Integer minUsage; + private Integer orderCount; + + public UserGradeDTO(UserGrade userGrade) { + if (userGrade != null) { + this.gradeId = userGrade.getGradeId(); + this.gradeName = userGrade.getGradeName(); + this.discount = userGrade.getDiscount(); + this.mileageRate = userGrade.getMileageRate(); + this.minUsage = userGrade.getMinUsage(); + this.orderCount = userGrade.getOrderCount(); + } + } +} \ 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 new file mode 100644 index 0000000..e1bf658 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/repository/BookRepository.java @@ -0,0 +1,20 @@ +package com.fastcampus.book_bot.repository; + +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; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface BookRepository extends JpaRepository { + + Optional findByBookIsbn(String bookIsbn); + Page findByBookNameContaining(String bookTitle, Pageable pageable); + Page findByBookAuthorContaining(String bookAuthor, Pageable pageable); + Page findByBookPublisherContaining(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/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/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/api/ApiToMySQLService.java b/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java new file mode 100644 index 0000000..a5dc1a7 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/service/api/ApiToMySQLService.java @@ -0,0 +1,91 @@ +package com.fastcampus.book_bot.service.api; + +import com.fastcampus.book_bot.common.response.ApiResponse; +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; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; +import java.util.Random; + +@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 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 ApiResponse.error("검색 결과가 없습니다."); + } + + saveBooks(response); + + return ApiResponse.success(response, "도서 저장 완료"); + } + + @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); + } + } catch (Exception e) { + { + log.error("도서 저장 중 오류 발생: {}, 도서: {}", e.getMessage(), item.getTitle()); + } + } + } + } + + private Book convertToBook(BookDTO item) { + return Book.builder() + .bookName(item.getTitle()) + .bookAuthor(item.getAuthor()) + .bookLink(item.getLink()) + .bookImagePath(item.getImage()) + .bookPublisher(item.getPublisher()) + .bookIsbn(String.valueOf(item.getIsbn())) + .bookDescription(item.getDescription()) + .bookPubdate(item.getPubdate()) + .bookDiscount(item.getDiscount()) + .build(); + } + + private boolean isDuplicateBook(Book book) { + String isbn = book.getBookIsbn(); + + if (isbn == null || isbn.trim().isEmpty() || isbn.equals("0")) { + return false; + } + + Optional existingBook = bookRepository.findByBookIsbn(isbn); + return existingBook.isPresent(); + } +} 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 new file mode 100644 index 0000000..d2ead3b --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/service/api/NaverBookAPIService.java @@ -0,0 +1,61 @@ +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 NaverBookAPIService { + + /* 네이버 API 요청&응답 클래스 작성 + - 비동기식 + - 응답 타임아웃 30초 + * */ + + @Value("${naver.api.client-id}") + private String clientId; + + @Value("${naver.api.client-secret}") + private String clientSecret; + + private final WebClient webClient; + + public NaverBookAPIService(WebClient webClient) { + this.webClient = webClient; + } + + public NaverBookResponseDTO 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()) + .block(Duration.ofSeconds(30)); + } +} 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..6be852b --- /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(false); // HTTP에서만 전송 + 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/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..e7b0cf2 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/service/auth/AuthService.java @@ -0,0 +1,177 @@ +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; +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.Map; + +@Service +@Slf4j +@RequiredArgsConstructor +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("닉네임 중복 검증 실패: {}", 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("이메일 중복 검증 실패: {}", 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 (isDuplicateEmail(request.getEmail())) { + throw UserDomainException.conflict( + "이미 존재하는 이메일입니다: " + request.getEmail(), + UserErrorCode.EMAIL_ALREADY_EXISTS.getCode(), + Map.of("email", request.getEmail()) + ); + } + + if (isDuplicateNickname(request.getNickname())) { + 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(); + + userRepository.save(user); + log.info("회원가입 완료: {}", request.getEmail()); + + } catch (UserDomainException e) { + throw e; + } catch (Exception 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 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 UserDomainException.unauthorized( + UserErrorCode.INVALID_CREDENTIALS.getMessage(), + UserErrorCode.INVALID_CREDENTIALS.getCode(), + Map.of("email", request.getEmail()) + ); + } + + if (!encoderUtils.validatePassword(request.getPassword(), user.getUserPassword())) { + 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) { + 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 new file mode 100644 index 0000000..85eacea --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/service/auth/MailService.java @@ -0,0 +1,127 @@ +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 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.Map; +import java.util.Random; + +@Service +@Slf4j +@RequiredArgsConstructor +public class MailService { + + private final TemplateEngine templateEngine; + private final JavaMailSender javaMailSender; + private final RedisTemplate redisTemplate; + + private static final String EMAIL_VERIFICATION_PREFIX = "email_verification:"; + private static final int VERIFICATION_EXPIRE_MINUTES = 10; + + /** 이메일 보내기 + * @param userEmail 사용자 이메일 + * */ + 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("메일 전송 실패: {}", userEmail, e); + throw UserDomainException.unprocessableEntity( + UserErrorCode.EMAIL_SEND_FAILED.getMessage(), + UserErrorCode.EMAIL_SEND_FAILED.getCode(), + Map.of("email", userEmail) + ); + } + } + + /** 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() + ); + } + + 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 인증 번호 검증 + * @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 = redisTemplate.opsForValue().get(key); + + if (storedCode != null && storedCode.equals(inputCode)) { + redisTemplate.delete(key); + return true; + } else { + throw UserDomainException.badRequest(UserErrorCode.VERIFICATION_CODE_INVALID.getMessage(), + UserErrorCode.VERIFICATION_CODE_INVALID.getCode()); + } + } catch (Exception e) { + log.error("인증 코드 검증 실패: {}", userEmail, e); + throw UserDomainException.internalServerError( + UserErrorCode.SYSTEM_ERROR.getMessage(), + UserErrorCode.SYSTEM_ERROR.getCode(), + Map.of("email", userEmail) + ); + } + } +} \ 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..c29cb28 --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/service/book/BookSearchService.java @@ -0,0 +1,61 @@ +package com.fastcampus.book_bot.service.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; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@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.findByBookNameContaining(keyword, pageable); + case "author": + return bookRepository.findByBookAuthorContaining(keyword, pageable); + case "publisher": + return bookRepository.findByBookPublisherContaining(keyword, pageable); + case "all": + default: + return bookRepository.findByBookNameContainingOrBookAuthorContainingOrBookPublisherContaining(keyword, keyword, keyword, pageable); + } + } catch (Exception e) { + log.warn("도서 검색 중 오류 발생! 조건: {}, 키워드 {}", searchType, keyword); + return Page.empty(pageable); + } + } + + @Transactional(readOnly = true) + public Optional getBookById(Integer 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/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..fc0291d --- /dev/null +++ b/src/main/java/com/fastcampus/book_bot/service/order/OrderService.java @@ -0,0 +1,27 @@ +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 +@RequiredArgsConstructor +public class OrderService { + + private final OrderRepository orderRepository; + private final OrderBookRepository orderBookRepository; + + + @Transactional + public void orderBook(OrdersDTO ordersDTO) { + + /* ordersDTO to order Entity */ + + } + +} 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 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 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/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 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/db/migration/V9__Alter_column_usergrade.sql b/src/main/resources/db/migration/V9__Alter_column_usergrade.sql new file mode 100644 index 0000000..b28a1f1 --- /dev/null +++ b/src/main/resources/db/migration/V9__Alter_column_usergrade.sql @@ -0,0 +1,3 @@ +ALTER TABLE `user_grade` + MODIFY COLUMN `DISCOUNT` DECIMAL(8,6) NULL, + MODIFY COLUMN `MILEAGE_RATE` DECIMAL(8,6) NULL; \ 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..f3ca014 --- /dev/null +++ b/src/main/resources/templates/auth/login.html @@ -0,0 +1,495 @@ + + + + + + 로그인 - 온라인 서점 + + + + + + +
+ + + + + + + \ 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..a5f110a --- /dev/null +++ b/src/main/resources/templates/auth/signup.html @@ -0,0 +1,924 @@ + + + + + + 회원가입 - 온라인 서점 + + + + + + +
+ + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/book/detail.html b/src/main/resources/templates/book/detail.html new file mode 100644 index 0000000..53c8c38 --- /dev/null +++ b/src/main/resources/templates/book/detail.html @@ -0,0 +1,379 @@ + + + + + + 도서 상세 - 온라인 서점 + + + + + + +
+ + +
+
+ + + +
+ +
+
+ +
+
+ + +
+ +

도서 제목

+ +

+ + 저자: 저자명 +

+ +

+ + 출판사: 출판사명 +

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

도서 소개

+
+ 도서에 대한 상세한 설명이 여기에 표시됩니다... +
+
+
+ + +
+
+ +
+
+
+
+ + + + + + + + + diff --git a/src/main/resources/templates/book/search.html b/src/main/resources/templates/book/search.html new file mode 100644 index 0000000..b5c2db6 --- /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..5d2d9d4 --- /dev/null +++ b/src/main/resources/templates/common/navigation.html @@ -0,0 +1,162 @@ + + + + + + + + + + \ 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 diff --git a/src/main/resources/templates/order/order.html b/src/main/resources/templates/order/order.html new file mode 100644 index 0000000..b2c89db --- /dev/null +++ b/src/main/resources/templates/order/order.html @@ -0,0 +1,579 @@ + + + + + + 주문하기 - 온라인 서점 + + + + + +
+ + + +

+ 주문하기 +

+ + + + + +

+ 주문 도서 +

+ +
+
+
+
+
책 제목
+
+ + 저자: 저자명 + + + 출판사: 출판사 + +
+
+ 가격 +
+
+ +
+ + + +
+
+
+
+ + +
+

포인트 사용

+
+ + +
+ ※ 보유 포인트 내에서만 사용 가능합니다 +
+ + +
+

+ 주문 요약 +

+
+ 상품 금액 + 0원 +
+
+ 등급 할인 + 0원 +
+
+ 포인트 사용 + 0원 +
+
+ 배송비 + 3,000원 +
+
+ 최종 결제 금액 + 0원 +
+ +
+ + 적립 예정 마일리지: 0P +
+
+ + +
+ + + + + + + + + + +
+
+ + + + \ No newline at end of file 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() { + } + +}