From 7d6ed8a550c4feebf53ac0274419870b835fb93c Mon Sep 17 00:00:00 2001 From: Byungung Lee <164743344+bulee5328@users.noreply.github.com> Date: Sun, 22 Jun 2025 19:31:32 +0900 Subject: [PATCH 001/339] Update issue templates --- .github/ISSUE_TEMPLATE/issue-template.md | 44 ++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/issue-template.md diff --git a/.github/ISSUE_TEMPLATE/issue-template.md b/.github/ISSUE_TEMPLATE/issue-template.md new file mode 100644 index 0000000..47cd6cd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue-template.md @@ -0,0 +1,44 @@ +--- +name: Issue Template +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +--- +name: Issue Template +about: 이슈 템플릿 +title: '' +labels: "✨ Feat" +assignees: '' + +--- + +📌 만들고자 하는 기능 +(여기에 기능 설명을 작성하세요) + +✅ 구현 내용 +[ ] (구체적인 구현 항목을 작성하세요) +[ ] (구체적인 구현 항목을 작성하세요) +[ ] (구체적인 구현 항목을 작성하세요) + +⏰ 예상 기간 +0월 00일 ~ 0월 00일 +💡 관련 이슈 +(관련있는 이슈 번호를 적어주세요.) + +📢 작업 내용 +(작업한 내용 작성) +(작업한 내용 작성) + +🗨️ 리뷰 요구사항(선택) +(리뷰 요구사항 작성) + +✅ 체크리스트 +[ ] 코드가 정상적으로 컴파일되나요? +[ ] 이슈 내용을 전부 구현했나요? +[ ] 작업 기간 내에 개발을 완료했나요? +[ ] 리뷰어를 선택했나요? +[ ] 라벨을 지정했나요? From e8c833fb143719ab3679b1c072caf22e712ced59 Mon Sep 17 00:00:00 2001 From: Kim Chanwoo <150686117+chanudevelop@users.noreply.github.com> Date: Sun, 22 Jun 2025 19:46:38 +0900 Subject: [PATCH 002/339] [Feature] Update issue-template.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이슈템플릿 수정 --- .github/ISSUE_TEMPLATE/issue-template.md | 45 ++++-------------------- 1 file changed, 7 insertions(+), 38 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/issue-template.md b/.github/ISSUE_TEMPLATE/issue-template.md index 47cd6cd..a3952a1 100644 --- a/.github/ISSUE_TEMPLATE/issue-template.md +++ b/.github/ISSUE_TEMPLATE/issue-template.md @@ -1,44 +1,13 @@ ---- -name: Issue Template -about: Suggest an idea for this project -title: '' -labels: '' -assignees: '' +## 📌 만들고자 하는 기능 ---- - ---- -name: Issue Template -about: 이슈 템플릿 -title: '' -labels: "✨ Feat" -assignees: '' - ---- - -📌 만들고자 하는 기능 (여기에 기능 설명을 작성하세요) -✅ 구현 내용 -[ ] (구체적인 구현 항목을 작성하세요) -[ ] (구체적인 구현 항목을 작성하세요) -[ ] (구체적인 구현 항목을 작성하세요) +## ✅ 구현 내용 -⏰ 예상 기간 -0월 00일 ~ 0월 00일 -💡 관련 이슈 -(관련있는 이슈 번호를 적어주세요.) - -📢 작업 내용 -(작업한 내용 작성) -(작업한 내용 작성) +- [ ] (구체적인 구현 항목을 작성하세요) +- [ ] (구체적인 구현 항목을 작성하세요) +- [ ] (구체적인 구현 항목을 작성하세요) -🗨️ 리뷰 요구사항(선택) -(리뷰 요구사항 작성) +## ⏰ 예상 기간 -✅ 체크리스트 -[ ] 코드가 정상적으로 컴파일되나요? -[ ] 이슈 내용을 전부 구현했나요? -[ ] 작업 기간 내에 개발을 완료했나요? -[ ] 리뷰어를 선택했나요? -[ ] 라벨을 지정했나요? +0월 00일 ~ 0월 00일 From d1fea8fcdc2474414fccc138ff6d12b0dc277ae3 Mon Sep 17 00:00:00 2001 From: Kim Chanwoo <150686117+chanudevelop@users.noreply.github.com> Date: Sun, 22 Jun 2025 19:49:19 +0900 Subject: [PATCH 003/339] =?UTF-8?q?[Fix]=20issue-template.md=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ISSUE_TEMPLATE/issue-template.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/issue-template.md b/.github/ISSUE_TEMPLATE/issue-template.md index a3952a1..d5a0378 100644 --- a/.github/ISSUE_TEMPLATE/issue-template.md +++ b/.github/ISSUE_TEMPLATE/issue-template.md @@ -1,3 +1,12 @@ +--- +name: Issue Template +about: 이슈 템플릿 +title: '' +labels: "✨ Feat" +assignees: '' + +--- + ## 📌 만들고자 하는 기능 (여기에 기능 설명을 작성하세요) From 0b7f21a32baed514808fe857cfec5f5e23d6dab3 Mon Sep 17 00:00:00 2001 From: Kim Chanwoo <150686117+chanudevelop@users.noreply.github.com> Date: Sun, 22 Jun 2025 19:51:05 +0900 Subject: [PATCH 004/339] =?UTF-8?q?[Feature]=20PULL=5FREQUEST=5FTEMPLATE?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE.md diff --git a/.github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE.md b/.github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..c5d6b0d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,20 @@ +## 💡 관련 이슈 + +(관련있는 이슈 번호를 적어주세요.) + +## 📢 작업 내용 + +- (작업한 내용 작성) +- (작업한 내용 작성) + +## 🗨️ 리뷰 요구사항(선택) + +- (리뷰 요구사항 작성) + +## ✅ 체크리스트 + +- [ ] 코드가 정상적으로 컴파일되나요? +- [ ] 이슈 내용을 전부 구현했나요? +- [ ] 작업 기간 내에 개발을 완료했나요? +- [ ] 리뷰어를 선택했나요? +- [ ] 라벨을 지정했나요? From 0b399fd74d88de23e1f11a8003966599838db71d Mon Sep 17 00:00:00 2001 From: bulee5328 Date: Mon, 23 Jun 2025 20:39:02 +0900 Subject: [PATCH 005/339] =?UTF-8?q?[Setting]=20=ED=94=84=EB=A1=9C=EC=A0=9D?= =?UTF-8?q?=ED=8A=B8=20=EC=B4=88=EA=B8=B0=EC=84=B8=ED=8C=85=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitattributes | 3 + .gitignore | 37 +++ build.gradle | 110 ++++++++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 251 ++++++++++++++++++ gradlew.bat | 94 +++++++ settings.gradle | 1 + .../java/PerfumeOnMe/spring/Application.java | 13 + .../spring/apiPayload/ApiResponse.java | 37 +++ .../spring/apiPayload/code/BaseCode.java | 8 + .../spring/apiPayload/code/BaseErrorCode.java | 8 + .../apiPayload/code/ErrorReasonDTO.java | 18 ++ .../spring/apiPayload/code/ReasonDTO.java | 18 ++ .../apiPayload/code/status/ErrorStatus.java | 40 +++ .../apiPayload/code/status/SuccessStatus.java | 40 +++ .../apiPayload/exception/ExceptionAdvice.java | 119 +++++++++ .../exception/GeneralException.java | 21 ++ src/main/resources/application.properties | 1 + src/main/resources/application.yml | 0 .../PerfumeOnMe/spring/ApplicationTests.java | 13 + 21 files changed, 839 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 build.gradle create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 src/main/java/PerfumeOnMe/spring/Application.java create mode 100644 src/main/java/PerfumeOnMe/spring/apiPayload/ApiResponse.java create mode 100644 src/main/java/PerfumeOnMe/spring/apiPayload/code/BaseCode.java create mode 100644 src/main/java/PerfumeOnMe/spring/apiPayload/code/BaseErrorCode.java create mode 100644 src/main/java/PerfumeOnMe/spring/apiPayload/code/ErrorReasonDTO.java create mode 100644 src/main/java/PerfumeOnMe/spring/apiPayload/code/ReasonDTO.java create mode 100644 src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java create mode 100644 src/main/java/PerfumeOnMe/spring/apiPayload/code/status/SuccessStatus.java create mode 100644 src/main/java/PerfumeOnMe/spring/apiPayload/exception/ExceptionAdvice.java create mode 100644 src/main/java/PerfumeOnMe/spring/apiPayload/exception/GeneralException.java create mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/application.yml create mode 100644 src/test/java/PerfumeOnMe/spring/ApplicationTests.java diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..83c581f --- /dev/null +++ b/build.gradle @@ -0,0 +1,110 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.5.3' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'PerfumeOnMe' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } + querydsl { } +} + +repositories { + mavenCentral() +} + +ext { + querydslVersion = '5.1.0' +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // MySQL Driver +// runtimeOnly 'com.mysql:mysql-connector-j' + + // ✅ QueryDSL (5.1.0) + implementation "com.querydsl:querydsl-jpa:$querydslVersion:jakarta" + annotationProcessor "com.querydsl:querydsl-apt:$querydslVersion:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + // 테스트 관련 의존성 + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + // validation + implementation 'org.springframework.boot:spring-boot-starter-validation' + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + // Thymeleaf + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.1.RELEASE' + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' +} + +tasks.named('test') { + useJUnitPlatform() +} + +// QueryDSL 설정부 +def querydslDir = file("src/main/generated") + +sourceSets { + main { + java { + srcDirs += querydslDir + } + } +} + +// Q 클래스 중복 생성 방지 및 자동 생성 +tasks.named('compileJava') { + doFirst { + if (querydslDir.exists()) { + querydslDir.deleteDir() // Q 클래스 중복 생성 방지 + } + querydslDir.mkdirs() + } +} + +tasks.register("compileQuerydsl", JavaCompile) { + group = "build" + description = "Generates the QueryDSL Q-types" + source = sourceSets.main.java + destinationDirectory = querydslDir + classpath = sourceSets.main.compileClasspath + options.annotationProcessorPath = configurations.annotationProcessor + options.compilerArgs = [ + '-proc:only', + '-processor', 'com.querydsl.apt.jpa.JPAAnnotationProcessor' + ] +} + +// compileJava는 항상 compileQuerydsl 이후에 실행 +compileJava { + dependsOn compileQuerydsl +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..ebf1ef8 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'spring' diff --git a/src/main/java/PerfumeOnMe/spring/Application.java b/src/main/java/PerfumeOnMe/spring/Application.java new file mode 100644 index 0000000..2913dd5 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/Application.java @@ -0,0 +1,13 @@ +package PerfumeOnMe.spring; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + +} diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/ApiResponse.java b/src/main/java/PerfumeOnMe/spring/apiPayload/ApiResponse.java new file mode 100644 index 0000000..a8450db --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/ApiResponse.java @@ -0,0 +1,37 @@ +package PerfumeOnMe.spring.apiPayload; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@JsonPropertyOrder({"isSuccess", "code", "message", "result"}) +public class ApiResponse { + + @JsonProperty("isSuccess") + private final Boolean isSuccess; + private final String code; + private final String message; + @JsonInclude(JsonInclude.Include.NON_NULL) + private T result; + + + // 성공한 경우 응답 생성 + +// public static ApiResponse onSuccess(T result){ +// return new ApiResponse<>(true, SuccessStatus._OK.getCode() , SuccessStatus._OK.getMessage(), result); +// } +// +// public static ApiResponse of(BaseCode code, T result){ + // return new ApiResponse<>(true, code.getReasonHttpStatus().getCode() , code.getReasonHttpStatus().getMessage(), result); +// } + + + // 실패한 경우 응답 생성 + public static ApiResponse onFailure(String code, String message, T data){ + return new ApiResponse<>(false, code, message, data); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/BaseCode.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/BaseCode.java new file mode 100644 index 0000000..cb169dd --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/BaseCode.java @@ -0,0 +1,8 @@ +package PerfumeOnMe.spring.apiPayload.code; + +public interface BaseCode { + + ReasonDTO getReason(); + + ReasonDTO getReasonHttpStatus(); +} diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/BaseErrorCode.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/BaseErrorCode.java new file mode 100644 index 0000000..9acb016 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/BaseErrorCode.java @@ -0,0 +1,8 @@ +package PerfumeOnMe.spring.apiPayload.code; + +public interface BaseErrorCode { + + ErrorReasonDTO getReason(); + + ErrorReasonDTO getReasonHttpStatus(); +} diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/ErrorReasonDTO.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/ErrorReasonDTO.java new file mode 100644 index 0000000..fc26836 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/ErrorReasonDTO.java @@ -0,0 +1,18 @@ +package PerfumeOnMe.spring.apiPayload.code; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@Builder +public class ErrorReasonDTO { + + private HttpStatus httpStatus; + + private final boolean isSuccess; + private final String code; + private final String message; + + public boolean getIsSuccess(){return isSuccess;} +} diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/ReasonDTO.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/ReasonDTO.java new file mode 100644 index 0000000..e4c9f20 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/ReasonDTO.java @@ -0,0 +1,18 @@ +package PerfumeOnMe.spring.apiPayload.code; + +import lombok.Builder; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@Builder +public class ReasonDTO { + + private HttpStatus httpStatus; + + private final boolean isSuccess; + private final String code; + private final String message; + + public boolean getIsSuccess(){return isSuccess;} +} diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java new file mode 100644 index 0000000..1e9fa95 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -0,0 +1,40 @@ +package PerfumeOnMe.spring.apiPayload.code.status; + +import PerfumeOnMe.spring.apiPayload.code.BaseErrorCode; +import PerfumeOnMe.spring.apiPayload.code.ErrorReasonDTO; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ErrorStatus implements BaseErrorCode { + + // 예시,,, + ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDTO getReason() { + return ErrorReasonDTO.builder() + .message(message) + .code(code) + .isSuccess(false) + .build(); + } + + @Override + public ErrorReasonDTO getReasonHttpStatus() { + return ErrorReasonDTO.builder() + .message(message) + .code(code) + .isSuccess(false) + .httpStatus(httpStatus) + .build() + ; + } +} + diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/SuccessStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/SuccessStatus.java new file mode 100644 index 0000000..26c3790 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/SuccessStatus.java @@ -0,0 +1,40 @@ +package PerfumeOnMe.spring.apiPayload.code.status; + +import PerfumeOnMe.spring.apiPayload.code.BaseCode; +import PerfumeOnMe.spring.apiPayload.code.ReasonDTO; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum SuccessStatus implements BaseCode { + + // 일반적인 응답 + _OK(HttpStatus.OK, "COMMON200", "성공입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ReasonDTO getReason() { + return ReasonDTO.builder() + .message(message) + .code(code) + .isSuccess(true) + .build(); + } + + @Override + public ReasonDTO getReasonHttpStatus() { + return ReasonDTO.builder() + .message(message) + .code(code) + .isSuccess(true) + .httpStatus(httpStatus) + .build() + ; + } +} + diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/exception/ExceptionAdvice.java b/src/main/java/PerfumeOnMe/spring/apiPayload/exception/ExceptionAdvice.java new file mode 100644 index 0000000..0ee7159 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/exception/ExceptionAdvice.java @@ -0,0 +1,119 @@ +package PerfumeOnMe.spring.apiPayload.exception; + +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.apiPayload.code.ErrorReasonDTO; +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.ServletWebRequest; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@RestControllerAdvice(annotations = {RestController.class}) +public class ExceptionAdvice extends ResponseEntityExceptionHandler { + + + @ExceptionHandler + public ResponseEntity validation(ConstraintViolationException e, WebRequest request) { + String errorMessage = e.getConstraintViolations().stream() + .map(constraintViolation -> constraintViolation.getMessage()) + .findFirst() + .orElseThrow(() -> new RuntimeException("ConstraintViolationException 추출 도중 에러 발생")); + + return handleExceptionInternalConstraint(e, ErrorStatus.valueOf(errorMessage), HttpHeaders.EMPTY,request); + } + + @Override + public ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException e, HttpHeaders headers, HttpStatusCode status, WebRequest request) { + + Map errors = new LinkedHashMap<>(); + + e.getBindingResult().getFieldErrors().stream() + .forEach(fieldError -> { + String fieldName = fieldError.getField(); + String errorMessage = Optional.ofNullable(fieldError.getDefaultMessage()).orElse(""); + errors.merge(fieldName, errorMessage, (existingErrorMessage, newErrorMessage) -> existingErrorMessage + ", " + newErrorMessage); + }); + + return handleExceptionInternalArgs(e,HttpHeaders.EMPTY,ErrorStatus.valueOf("_BAD_REQUEST"),request,errors); + } + + @ExceptionHandler + public ResponseEntity exception(Exception e, WebRequest request) { + e.printStackTrace(); + + return handleExceptionInternalFalse(e, ErrorStatus.ARTICLE_NOT_FOUND, HttpHeaders.EMPTY, ErrorStatus.ARTICLE_NOT_FOUND.getHttpStatus(),request, e.getMessage()); + } + + @ExceptionHandler(value = GeneralException.class) + public ResponseEntity onThrowException(GeneralException generalException, HttpServletRequest request) { + ErrorReasonDTO errorReasonHttpStatus = generalException.getErrorReasonHttpStatus(); + return handleExceptionInternal(generalException,errorReasonHttpStatus,null,request); + } + + private ResponseEntity handleExceptionInternal(Exception e, ErrorReasonDTO reason, + HttpHeaders headers, HttpServletRequest request) { + + ApiResponse body = ApiResponse.onFailure(reason.getCode(),reason.getMessage(),null); +// e.printStackTrace(); + + WebRequest webRequest = new ServletWebRequest(request); + return super.handleExceptionInternal( + e, + body, + headers, + reason.getHttpStatus(), + webRequest + ); + } + + private ResponseEntity handleExceptionInternalFalse(Exception e, ErrorStatus errorCommonStatus, + HttpHeaders headers, HttpStatus status, WebRequest request, String errorPoint) { + ApiResponse body = ApiResponse.onFailure(errorCommonStatus.getCode(),errorCommonStatus.getMessage(),errorPoint); + return super.handleExceptionInternal( + e, + body, + headers, + status, + request + ); + } + + private ResponseEntity handleExceptionInternalArgs(Exception e, HttpHeaders headers, ErrorStatus errorCommonStatus, + WebRequest request, Map errorArgs) { + ApiResponse body = ApiResponse.onFailure(errorCommonStatus.getCode(),errorCommonStatus.getMessage(),errorArgs); + return super.handleExceptionInternal( + e, + body, + headers, + errorCommonStatus.getHttpStatus(), + request + ); + } + + private ResponseEntity handleExceptionInternalConstraint(Exception e, ErrorStatus errorCommonStatus, + HttpHeaders headers, WebRequest request) { + ApiResponse body = ApiResponse.onFailure(errorCommonStatus.getCode(), errorCommonStatus.getMessage(), null); + return super.handleExceptionInternal( + e, + body, + headers, + errorCommonStatus.getHttpStatus(), + request + ); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/exception/GeneralException.java b/src/main/java/PerfumeOnMe/spring/apiPayload/exception/GeneralException.java new file mode 100644 index 0000000..0cb238a --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/exception/GeneralException.java @@ -0,0 +1,21 @@ +package PerfumeOnMe.spring.apiPayload.exception; + +import PerfumeOnMe.spring.apiPayload.code.BaseErrorCode; +import PerfumeOnMe.spring.apiPayload.code.ErrorReasonDTO; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class GeneralException extends RuntimeException { + + private BaseErrorCode code; + + public ErrorReasonDTO getErrorReason() { + return this.code.getReason(); + } + + public ErrorReasonDTO getErrorReasonHttpStatus(){ + return this.code.getReasonHttpStatus(); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 0000000..0c7c661 --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.application.name=spring diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..e69de29 diff --git a/src/test/java/PerfumeOnMe/spring/ApplicationTests.java b/src/test/java/PerfumeOnMe/spring/ApplicationTests.java new file mode 100644 index 0000000..762751e --- /dev/null +++ b/src/test/java/PerfumeOnMe/spring/ApplicationTests.java @@ -0,0 +1,13 @@ +package PerfumeOnMe.spring; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ApplicationTests { + + @Test + void contextLoads() { + } + +} From d50baabfe7387b02ac1a9075c39099d646a402ee Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Mon, 30 Jun 2025 16:22:37 +0900 Subject: [PATCH 006/339] =?UTF-8?q?[Feature]=20=EC=97=94=ED=8B=B0=ED=8B=B0?= =?UTF-8?q?=20=EC=84=A4=EA=B3=84=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 0 -> 6148 bytes src/.DS_Store | Bin 0 -> 6148 bytes src/main/.DS_Store | Bin 0 -> 6148 bytes src/main/java/.DS_Store | Bin 0 -> 6148 bytes src/main/java/PerfumeOnMe/.DS_Store | Bin 0 -> 6148 bytes .../java/PerfumeOnMe/spring/Application.java | 4 + .../spring/apiPayload/ApiResponse.java | 17 ++- .../spring/config/SwaggerConfig.java | 40 +++++++ .../spring/converter/UserConverter.java | 4 + .../spring/domain/ChatMessage.java | 28 +++++ .../PerfumeOnMe/spring/domain/Fragrance.java | 98 ++++++++++++++++++ .../spring/domain/FragranceKeyword.java | 24 +++++ .../spring/domain/FragrancePrice.java | 26 +++++ .../spring/domain/ImageKeyword.java | 27 +++++ .../PerfumeOnMe/spring/domain/Location.java | 28 +++++ .../java/PerfumeOnMe/spring/domain/Note.java | 51 +++++++++ .../java/PerfumeOnMe/spring/domain/PBTI.java | 27 +++++ .../PerfumeOnMe/spring/domain/Season.java | 26 +++++ .../java/PerfumeOnMe/spring/domain/Terms.java | 32 ++++++ .../java/PerfumeOnMe/spring/domain/User.java | 89 ++++++++++++++++ .../PerfumeOnMe/spring/domain/Workshop.java | 27 +++++ .../spring/domain/base/BaseEntity.java | 22 ++++ .../PerfumeOnMe/spring/domain/enums/Age.java | 5 + .../spring/domain/enums/Brand.java | 5 + .../spring/domain/enums/ChatSender.java | 5 + .../spring/domain/enums/FragranceGender.java | 5 + .../spring/domain/enums/FragranceType.java | 21 ++++ .../spring/domain/enums/Social.java | 5 + .../spring/domain/enums/UserGender.java | 5 + .../spring/domain/enums/UserStatus.java | 5 + .../spring/domain/mapping/Diary.java | 35 +++++++ .../domain/mapping/FragranceBaseNote.java | 26 +++++ .../domain/mapping/FragranceLocation.java | 27 +++++ .../domain/mapping/FragranceMiddleNote.java | 26 +++++ .../domain/mapping/FragranceSeason.java | 27 +++++ .../domain/mapping/FragranceTopNote.java | 27 +++++ .../domain/mapping/RecommendedFragrance.java | 41 ++++++++ .../spring/domain/mapping/UserFragrance.java | 26 +++++ .../spring/domain/mapping/UserNote.java | 27 +++++ .../spring/domain/mapping/UserTerms.java | 29 ++++++ .../spring/repository/UserRepository.java | 4 + .../spring/security/SecurityConfig.java | 4 + .../spring/service/UserService.java | 4 + .../validation/annotation/ExistUser.java | 4 + .../validator/ExistUserValidator.java | 4 + .../spring/web/controller/UserController.java | 4 + .../spring/web/dto/user/UserRequestDTO.java | 4 + .../spring/web/dto/user/UserResponseDTO.java | 4 + 48 files changed, 940 insertions(+), 9 deletions(-) create mode 100644 .DS_Store create mode 100644 src/.DS_Store create mode 100644 src/main/.DS_Store create mode 100644 src/main/java/.DS_Store create mode 100644 src/main/java/PerfumeOnMe/.DS_Store create mode 100644 src/main/java/PerfumeOnMe/spring/config/SwaggerConfig.java create mode 100644 src/main/java/PerfumeOnMe/spring/converter/UserConverter.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/ChatMessage.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/Fragrance.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/FragranceKeyword.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/FragrancePrice.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/Location.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/Note.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/PBTI.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/Season.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/Terms.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/User.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/Workshop.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/base/BaseEntity.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/enums/Age.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/enums/Brand.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/enums/ChatSender.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/enums/FragranceGender.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/enums/FragranceType.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/enums/Social.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/enums/UserGender.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/enums/UserStatus.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/mapping/Diary.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceBaseNote.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceLocation.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceMiddleNote.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceSeason.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceTopNote.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/mapping/RecommendedFragrance.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/mapping/UserFragrance.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/mapping/UserNote.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/mapping/UserTerms.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/UserRepository.java create mode 100644 src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java create mode 100644 src/main/java/PerfumeOnMe/spring/service/UserService.java create mode 100644 src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUser.java create mode 100644 src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserValidator.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/controller/UserController.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/user/UserResponseDTO.java diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..3c47f0fffc1a49ee516a728e62e446dbc1cc56f0 GIT binary patch literal 6148 zcmeHKO^?$s5FNK=H=!VU0BJ8sk+`l(yC6QqC6w-g1D6eg1E7*@swE;DuacA;s!BP- zUqRx^FX6v%f;YAok{0%eQ01w{Z(`4j<>%P0iAYQq@t9~pL=K#>cNxVWjN92atYd2W zK%w3d(~wRmr;y@}Y+GR!unPQd3h>&!LJ=(}p%kn3_xep~{xp@xQ7R+k@CNZ1KK!8! z5z)@CwZ57A<)YlED$dM%zeZaiO?@Zg5=JoelPu%khKJzISkc zyxRAc4-XLEy>oB1a-AEuZa+Gjy-!aw`B9CK1oky$R}G%SCj_nWe+cp{mDvgEnX}u} zhN`xb;wCbhO7`2?n1u*?Xt~DXKAS9;j_44xGNot0A)QjZ#`wO*xJCudsl+d#41E?C z&3A{#((JE`{G5*4eWp-Ed0Kguql_BQ83e52g`UY!_Xn_e$1!}4=R}7))sP{~sZ?M*==_JhMLzte0 zxuFQvJKpc9bP~QsTUrII0&NAl`f7|80_evI%rf`**LG(s3<7RajXJ9iud5k(B^Rf^bOV;Q3JDo1e6T6unPQB1%3ib_2Uu% literal 0 HcmV?d00001 diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..99dc41154e05b8c05ec48df036bc54a32529de91 GIT binary patch literal 6148 zcmeHKO;6iE5S`-OB2t}UuT9h>A|J}w2++JBJkI(|8qVVZ6+h!! zu8-4Pr|9zoMP5GxymlU)Q$iUPPuA}X##a9rF%y}_rK9q~t@CYqk!NMqYW-G?8EpZ=;lk_m_kGjppuezv`tmqF+Le}deKl`uJ&Qzk8OS7y8Olukc?Q z`QY##u3=2)a~ThcTo)(salA(r^{J%l2_H8Yd&md6BEo<$APl@o2Hf80FTBaGmyZ?( zgn@s_0PhbWlreHxTQpk-8dCxQ`5I{iwz>KPLv{cohqXm`Aj+l!ZK`r5hH{mIvgsI) zTzqZOrjs&n#&g`8m8(#cD?C(a!bwFIr4VC1m22oFRq0)_@@gn|Fc Fz#U8SV2A(! literal 0 HcmV?d00001 diff --git a/src/main/.DS_Store b/src/main/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..0a1b57d4d390bd3518e6165368710405d4a2f66a GIT binary patch literal 6148 zcmeHKOHbQC5S~raVk<(*p_Ov9)GI0pMSZ9j6X<~}M&$q~wUY?kx?W`Gafl-M-2Rwe z`%Cyc?Kiudi02gnnvrI|+1VM}`>nORLqw`K8FqxVP~`D5z-!l_?-bLN;v4I?gR!^&jF^e6;*wc;;nw+Ya*?IwxYM~*wR+>l z%QesQn%-%2u4mCW9#6_%TpV-nQ0pvN+HvwV9nJc!SGzhN$7wz)OhP&wA>`yJ&4+r{ z)02Eyn%u|^c)suVTkZM0`?0eTY<=i1HiG%5E!f-d-Y*uu_xjD|=Y#%_>}RfjS?U%3 zOC#$Jm+%|LYChAb$TOXv!N>7F6*QufG)-U)*uKHcA4FYc23-+hKo}4Po)80WZ}gf^ z=$qwJg#lsUVHn{3A%HT54l9dh>p){l03cr@jlecne_+54VCb;22oFTrRG>{&uEbET za!@uMsE4uZJnQv-Q^G_^b_}-=HiUS6N)8z%WNKV)-a;Lyf>6umKDmRuj@YAdRg14ubq;u=Acq87x(4e6nWst6-E07_%xf`#jioP->rNIuga z;!F6Q_Ra1F6@^?Qf@Y-IH{P9Ruisj`9wJh`*{DO*AR-UWSn<*P#dw^3%_^>^3RLDE zGrFdPE-0t-wQTA57Zu>OtJ7<&T0vKIfBllPOn;6u9V3H3j}!QlTVseevf}w<>{vhJ zah6V|?e;HKsn+%$?0cS9_dZ2m^*ovmr?W|KSbX5Cw_0cMDjvq~(qukpJbb0|=`hWc z!VuC?f|QT%(|n}oJw3}u6T^+{fam-EpwV0`I?vm!pnKd|wt~frF5+j0N6V$}J$n4~ z<(t8`>^j#!%=}5=n>BXea0NFAHfZuaD)LO{Uy!k4uULYjfG8je?2ZC%2lMK?Gi8~n zC?E=KNdev;0ytynu(W8l4m73&0M^iL3^D&KGAD2tIxH<>1g0z%XsODT7|PPoAGo~G zVQJCQNx9-fxhE@Ep(yio+&?hsq(Y0*iUOj*p9*m82W#;8f4aH;H%Zcn0;0hGser2W zqka$X=FZl&x8$=nfM3GdIIpz$n}Wa`#mMEOxCu9g{(u8u=&-bi9+>S5T0#oO(;SS3OxqA7HzBegO^zA!K)EHsMN%k8jRV}r1nq>IqM7gB)*Q$ z>~4foy@^Pff!S{|JCkI;4LcbC5bbfV2~Y(93ze{7FhZGUu4R~J%7L2moOW!Rr|tLrB+PW>?Mk5od~>to2xRT%f=q%FsB zFHyOkp0I4&?pABlY4e~_cUt?+S>2f)w$R?)*_+L5Yh!c!=)C(FJ;m}xy)_8@K9#H) z%;6P{xivj|qd1cB0R4GR9?QrKFaylMvM^xIH>3Dxfg@bT4^2iJ@1M>_}?}t*Q`hWU!|F430U { private T result; - // 성공한 경우 응답 생성 - -// public static ApiResponse onSuccess(T result){ -// return new ApiResponse<>(true, SuccessStatus._OK.getCode() , SuccessStatus._OK.getMessage(), result); -// } -// -// public static ApiResponse of(BaseCode code, T result){ - // return new ApiResponse<>(true, code.getReasonHttpStatus().getCode() , code.getReasonHttpStatus().getMessage(), result); -// } + public static ApiResponse onSuccess(T result){ + return new ApiResponse<>(true, SuccessStatus._OK.getCode() , SuccessStatus._OK.getMessage(), result); + } + public static ApiResponse of(BaseCode code, T result){ + return new ApiResponse<>(true, code.getReasonHttpStatus().getCode() , code.getReasonHttpStatus().getMessage(), result); + } // 실패한 경우 응답 생성 public static ApiResponse onFailure(String code, String message, T data){ diff --git a/src/main/java/PerfumeOnMe/spring/config/SwaggerConfig.java b/src/main/java/PerfumeOnMe/spring/config/SwaggerConfig.java new file mode 100644 index 0000000..80cca33 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/SwaggerConfig.java @@ -0,0 +1,40 @@ +package PerfumeOnMe.spring.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI PerFumeOnMeAPI() { + Info info = new Info() + .title("PerFume On Me") + .description("PerFume On Me API 명세서") + .version("1.0.0"); + + String jwtSchemeName = "JWT TOKEN"; + // API 요청헤더에 인증정보 포함 + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName); + // SecuritySchemes 등록 + Components components = new Components() + .addSecuritySchemes(jwtSchemeName, new SecurityScheme() + .name(jwtSchemeName) + .type(SecurityScheme.Type.HTTP) // HTTP 방식 + .scheme("bearer") + .bearerFormat("JWT")); + + return new OpenAPI() + .addServersItem(new Server().url("/")) + .info(info) + .addSecurityItem(securityRequirement) + .components(components); + } +} +// http://localhost:8080/swagger-ui/index.html#/ diff --git a/src/main/java/PerfumeOnMe/spring/converter/UserConverter.java b/src/main/java/PerfumeOnMe/spring/converter/UserConverter.java new file mode 100644 index 0000000..034065e --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/converter/UserConverter.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.converter; + +public class UserConverter { +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/ChatMessage.java b/src/main/java/PerfumeOnMe/spring/domain/ChatMessage.java new file mode 100644 index 0000000..06f7fd0 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/ChatMessage.java @@ -0,0 +1,28 @@ +package PerfumeOnMe.spring.domain; + +import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.domain.enums.ChatSender; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class ChatMessage extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "VARCHAR(10)", nullable = false) + private ChatSender senderType; +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java b/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java new file mode 100644 index 0000000..38d0604 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java @@ -0,0 +1,98 @@ +package PerfumeOnMe.spring.domain; + +import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.domain.enums.*; +import PerfumeOnMe.spring.domain.mapping.*; +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Fragrance extends BaseEntity { + + // ----- 필드 ----- + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 30) + private String name; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "VARCHAR(15)") + private Brand brand; + + @Column(nullable = false) + private String description; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "VARCHAR(10)") + private FragranceGender gender; + + @Column(columnDefinition = "TEXT", nullable = false) + private String homePageURL; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "VARCHAR(30)", nullable = false) + private FragranceType fragranceType; + + @Column(columnDefinition = "TEXT", nullable = false) + private String ImageURL; + + @Column(nullable = false) + private String topNoteDescription; + + @Column(nullable = false) + private String middleNoteDescription; + + @Column(nullable = false) + private String baseNoteDescription; + + @Column(nullable = false) + private String topNoteKeyword; + + @Column(nullable = false) + private String middleNoteKeyword; + + @Column(nullable = false) + private String baseNoteKeyword; + + //----- 매핑 관계 ----- + + @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + private List userFragranceList = new ArrayList<>(); + + @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + private List diaryList = new ArrayList<>(); + + @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + private List fragranceSeasonList = new ArrayList<>(); + + @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + private List fragranceLocationList = new ArrayList<>(); + + @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + private List fragranceKeywordList = new ArrayList<>(); + + @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + private List fragrancePriceList = new ArrayList<>(); + + @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + private List fragranceTopNoteList = new ArrayList<>(); + + @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + private List fragranceMiddleNoteList = new ArrayList<>(); + + @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + private List fragranceBaseNoteList = new ArrayList<>(); + + @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + private List RecommendedFragranceList = new ArrayList<>(); + +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/FragranceKeyword.java b/src/main/java/PerfumeOnMe/spring/domain/FragranceKeyword.java new file mode 100644 index 0000000..2eaa54d --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/FragranceKeyword.java @@ -0,0 +1,24 @@ +package PerfumeOnMe.spring.domain; + +import PerfumeOnMe.spring.domain.base.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class FragranceKeyword extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 20) + private String name; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fragrance_id") + private Fragrance fragrance; + +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/FragrancePrice.java b/src/main/java/PerfumeOnMe/spring/domain/FragrancePrice.java new file mode 100644 index 0000000..f7f1682 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/FragrancePrice.java @@ -0,0 +1,26 @@ +package PerfumeOnMe.spring.domain; + +import PerfumeOnMe.spring.domain.base.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class FragrancePrice extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private int mlCount; + + @Column(nullable = false) + private int price; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fragrance_id") + private Fragrance fragrance; +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java b/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java new file mode 100644 index 0000000..cf92bbd --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java @@ -0,0 +1,27 @@ +package PerfumeOnMe.spring.domain; + +import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.domain.mapping.RecommendedFragrance; +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class ImageKeyword extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @OneToMany(mappedBy = "imageKeyword", cascade = CascadeType.ALL) + private List RecommendedFragranceList = new ArrayList<>(); +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/Location.java b/src/main/java/PerfumeOnMe/spring/domain/Location.java new file mode 100644 index 0000000..55f5b09 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/Location.java @@ -0,0 +1,28 @@ +package PerfumeOnMe.spring.domain; + +import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.domain.mapping.FragranceLocation; +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Location extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 15) + private String name; + + @OneToMany(mappedBy = "location", cascade = CascadeType.ALL) + private List fragranceLocationList = new ArrayList<>(); + + +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/Note.java b/src/main/java/PerfumeOnMe/spring/domain/Note.java new file mode 100644 index 0000000..b6ebcf8 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/Note.java @@ -0,0 +1,51 @@ +package PerfumeOnMe.spring.domain; + +import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.domain.mapping.FragranceBaseNote; +import PerfumeOnMe.spring.domain.mapping.FragranceMiddleNote; +import PerfumeOnMe.spring.domain.mapping.FragranceTopNote; +import PerfumeOnMe.spring.domain.mapping.UserNote; +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Note extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 40) + private String name; + + @Column(nullable = false) + private String description; + + @Column(nullable = false) + private boolean top; + + @Column(nullable = false) + private boolean middle; + + @Column(nullable = false) + private boolean base; + + @OneToMany(mappedBy = "note", cascade = CascadeType.ALL) + private List fragranceTopNoteList = new ArrayList<>(); + + @OneToMany(mappedBy = "note", cascade = CascadeType.ALL) + private List fragranceMiddleNoteList = new ArrayList<>(); + + @OneToMany(mappedBy = "note", cascade = CascadeType.ALL) + private List fragranceBaseNoteList = new ArrayList<>(); + + @OneToMany(mappedBy = "note", cascade = CascadeType.ALL) + private List userNoteList = new ArrayList<>(); +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/PBTI.java b/src/main/java/PerfumeOnMe/spring/domain/PBTI.java new file mode 100644 index 0000000..a16ff36 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/PBTI.java @@ -0,0 +1,27 @@ +package PerfumeOnMe.spring.domain; + +import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.domain.mapping.RecommendedFragrance; +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class PBTI extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @OneToMany(mappedBy = "pbti", cascade = CascadeType.ALL) + private List RecommendedFragranceList = new ArrayList<>(); +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/Season.java b/src/main/java/PerfumeOnMe/spring/domain/Season.java new file mode 100644 index 0000000..5624b55 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/Season.java @@ -0,0 +1,26 @@ +package PerfumeOnMe.spring.domain; + +import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.domain.mapping.FragranceSeason; +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Season extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 15) + private String name; + + @OneToMany(mappedBy = "season", cascade = CascadeType.ALL) + private List fragranceSeasonList = new ArrayList<>(); +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/Terms.java b/src/main/java/PerfumeOnMe/spring/domain/Terms.java new file mode 100644 index 0000000..9fb1f77 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/Terms.java @@ -0,0 +1,32 @@ +package PerfumeOnMe.spring.domain; + +import PerfumeOnMe.spring.domain.mapping.UserTerms; +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Terms { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 100) + private String title; + + @Column(columnDefinition = "TEXT",nullable = false) + private String content; + + @Column(nullable = false) + private boolean required; + + @OneToMany(mappedBy = "terms", cascade = CascadeType.ALL) + private List userTermsList = new ArrayList<>(); +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/User.java b/src/main/java/PerfumeOnMe/spring/domain/User.java new file mode 100644 index 0000000..901deb6 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/User.java @@ -0,0 +1,89 @@ +package PerfumeOnMe.spring.domain; + +import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.domain.enums.Age; +import PerfumeOnMe.spring.domain.enums.Social; +import PerfumeOnMe.spring.domain.enums.UserGender; +import PerfumeOnMe.spring.domain.enums.UserStatus; +import PerfumeOnMe.spring.domain.mapping.Diary; +import PerfumeOnMe.spring.domain.mapping.UserFragrance; +import PerfumeOnMe.spring.domain.mapping.UserNote; +import PerfumeOnMe.spring.domain.mapping.UserTerms; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class User extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true, length = 25) + private String name; + + @Column(unique = true, length = 25) + private String nickname; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "VARCHAR(10) DEFAULT 'NONE'") + private Age age; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "VARCHAR(10) DEFAULT 'NONE'") + private UserGender gender; + + @Column(nullable = false, unique = true, length = 30) + private String email; + + @Column(nullable = false, unique = true, length = 30) + private String loginId; + + @Column(columnDefinition = "TEXT", nullable = false) + private String password; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "VARCHAR(10) DEFAULT 'LOCAL'") + private Social social; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "VARCHAR(10) DEFAULT 'ACTIVE'", nullable = false) + private UserStatus status; + + private LocalDate inactiveDate; + + @Column(columnDefinition = "TEXT") + private String imageURL; + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + private List userFragranceList = new ArrayList<>(); + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + private List userTermsList = new ArrayList<>(); + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + private List diaryList = new ArrayList<>(); + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + private List chatMessageList = new ArrayList<>(); + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + private List userNoteList = new ArrayList<>(); + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + private List pbtiList = new ArrayList<>(); + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + private List imageKeywordList = new ArrayList<>(); + + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + private List workshopList = new ArrayList<>(); +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/Workshop.java b/src/main/java/PerfumeOnMe/spring/domain/Workshop.java new file mode 100644 index 0000000..d1b1e97 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/Workshop.java @@ -0,0 +1,27 @@ +package PerfumeOnMe.spring.domain; + +import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.domain.mapping.RecommendedFragrance; +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Workshop extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @OneToMany(mappedBy = "workshop", cascade = CascadeType.ALL) + private List RecommendedFragranceList = new ArrayList<>(); +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/base/BaseEntity.java b/src/main/java/PerfumeOnMe/spring/domain/base/BaseEntity.java new file mode 100644 index 0000000..22e75a2 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/base/BaseEntity.java @@ -0,0 +1,22 @@ +package PerfumeOnMe.spring.domain.base; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +@Getter +public abstract class BaseEntity { + + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Age.java b/src/main/java/PerfumeOnMe/spring/domain/enums/Age.java new file mode 100644 index 0000000..cc43d7a --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/Age.java @@ -0,0 +1,5 @@ +package PerfumeOnMe.spring.domain.enums; + +public enum Age { + TEENAGER, TWENTIES, THIRTIES, FORTIES, NONE +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Brand.java b/src/main/java/PerfumeOnMe/spring/domain/enums/Brand.java new file mode 100644 index 0000000..cf9cd68 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/Brand.java @@ -0,0 +1,5 @@ +package PerfumeOnMe.spring.domain.enums; + +public enum Brand { + LOIVIE, DIPTYQUE, JOMALONE, MAISON_MARGIELA, FREDERIC_MALLE +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/ChatSender.java b/src/main/java/PerfumeOnMe/spring/domain/enums/ChatSender.java new file mode 100644 index 0000000..6ba7d1f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/ChatSender.java @@ -0,0 +1,5 @@ +package PerfumeOnMe.spring.domain.enums; + +public enum ChatSender { + USER, AI +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/FragranceGender.java b/src/main/java/PerfumeOnMe/spring/domain/enums/FragranceGender.java new file mode 100644 index 0000000..5ba77a1 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/FragranceGender.java @@ -0,0 +1,5 @@ +package PerfumeOnMe.spring.domain.enums; + +public enum FragranceGender { + MALE, FEMALE, NEUTRAL +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/FragranceType.java b/src/main/java/PerfumeOnMe/spring/domain/enums/FragranceType.java new file mode 100644 index 0000000..1c4d10c --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/FragranceType.java @@ -0,0 +1,21 @@ +package PerfumeOnMe.spring.domain.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + + +@Getter +@AllArgsConstructor +public enum FragranceType { + + PERFUME("6~8시간", "매우 강함"), + EAU_DE_PERFUME("4~6시간", "강함"), + EAU_DE_TOILETTE( "2~4시간", "보통"), + EAU_DE_COLOGNE("1~2시간", "부드러움"), + SHOWER_COLOGNE( "0.5~1시간", "매우 부드러움"); + + private final String lastingPower; // 지속 시간 + private final String diffusionPower; // 확산력 설명 + +} + diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Social.java b/src/main/java/PerfumeOnMe/spring/domain/enums/Social.java new file mode 100644 index 0000000..281364b --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/Social.java @@ -0,0 +1,5 @@ +package PerfumeOnMe.spring.domain.enums; + +public enum Social { + LOCAL, KAKAO +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/UserGender.java b/src/main/java/PerfumeOnMe/spring/domain/enums/UserGender.java new file mode 100644 index 0000000..4848958 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/UserGender.java @@ -0,0 +1,5 @@ +package PerfumeOnMe.spring.domain.enums; + +public enum UserGender { + MALE, FEMALE, NONE +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/UserStatus.java b/src/main/java/PerfumeOnMe/spring/domain/enums/UserStatus.java new file mode 100644 index 0000000..ff8534a --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/UserStatus.java @@ -0,0 +1,5 @@ +package PerfumeOnMe.spring.domain.enums; + +public enum UserStatus { + ACTIVE, INACTIVE, DELETED +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/Diary.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/Diary.java new file mode 100644 index 0000000..71e454f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/Diary.java @@ -0,0 +1,35 @@ +package PerfumeOnMe.spring.domain.mapping; + +import PerfumeOnMe.spring.domain.Fragrance; +import PerfumeOnMe.spring.domain.User; +import PerfumeOnMe.spring.domain.base.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDate; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Diary extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fragrance_id") + private Fragrance fragrance; + + @Column(columnDefinition = "TEXT", nullable = false) + private String content; + + @Column(nullable = false) + private LocalDate date; +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceBaseNote.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceBaseNote.java new file mode 100644 index 0000000..8c8f96f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceBaseNote.java @@ -0,0 +1,26 @@ +package PerfumeOnMe.spring.domain.mapping; + +import PerfumeOnMe.spring.domain.Fragrance; +import PerfumeOnMe.spring.domain.Note; +import PerfumeOnMe.spring.domain.base.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class FragranceBaseNote extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "note_id") + private Note note; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fragrance_id") + private Fragrance fragrance; +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceLocation.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceLocation.java new file mode 100644 index 0000000..9637b07 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceLocation.java @@ -0,0 +1,27 @@ +package PerfumeOnMe.spring.domain.mapping; + +import PerfumeOnMe.spring.domain.Fragrance; +import PerfumeOnMe.spring.domain.Location; +import PerfumeOnMe.spring.domain.base.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class FragranceLocation extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "location_id") + private Location location; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fragrance_id") + private Fragrance fragrance; + +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceMiddleNote.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceMiddleNote.java new file mode 100644 index 0000000..299630c --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceMiddleNote.java @@ -0,0 +1,26 @@ +package PerfumeOnMe.spring.domain.mapping; + +import PerfumeOnMe.spring.domain.Fragrance; +import PerfumeOnMe.spring.domain.Note; +import PerfumeOnMe.spring.domain.base.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class FragranceMiddleNote extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "note_id") + private Note note; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fragrance_id") + private Fragrance fragrance; +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceSeason.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceSeason.java new file mode 100644 index 0000000..bad69af --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceSeason.java @@ -0,0 +1,27 @@ +package PerfumeOnMe.spring.domain.mapping; + +import PerfumeOnMe.spring.domain.Fragrance; +import PerfumeOnMe.spring.domain.Season; +import PerfumeOnMe.spring.domain.base.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class FragranceSeason extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "season_id") + private Season season; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fragrance_id") + private Fragrance fragrance; + +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceTopNote.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceTopNote.java new file mode 100644 index 0000000..069d73c --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceTopNote.java @@ -0,0 +1,27 @@ +package PerfumeOnMe.spring.domain.mapping; + +import PerfumeOnMe.spring.domain.Fragrance; +import PerfumeOnMe.spring.domain.Location; +import PerfumeOnMe.spring.domain.Note; +import PerfumeOnMe.spring.domain.base.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class FragranceTopNote extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "note_id") + private Note note; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fragrance_id") + private Fragrance fragrance; +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/RecommendedFragrance.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/RecommendedFragrance.java new file mode 100644 index 0000000..236c0bc --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/RecommendedFragrance.java @@ -0,0 +1,41 @@ +package PerfumeOnMe.spring.domain.mapping; + +import PerfumeOnMe.spring.domain.Fragrance; +import PerfumeOnMe.spring.domain.ImageKeyword; +import PerfumeOnMe.spring.domain.PBTI; +import PerfumeOnMe.spring.domain.Workshop; +import PerfumeOnMe.spring.domain.base.BaseEntity; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.jdbc.Work; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class RecommendedFragrance extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 100) + private String name; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fragrance_id") + private Fragrance fragrance; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "imageKeyword_id") + private ImageKeyword imageKeyword; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "pbti_id") + private PBTI pbti; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "workshop_id") + private Workshop workshop; + +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/UserFragrance.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/UserFragrance.java new file mode 100644 index 0000000..dbe4ece --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/UserFragrance.java @@ -0,0 +1,26 @@ +package PerfumeOnMe.spring.domain.mapping; + +import PerfumeOnMe.spring.domain.Fragrance; +import PerfumeOnMe.spring.domain.User; +import PerfumeOnMe.spring.domain.base.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class UserFragrance extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fragrance_id") + private Fragrance fragrance; +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/UserNote.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/UserNote.java new file mode 100644 index 0000000..4f9e0c4 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/UserNote.java @@ -0,0 +1,27 @@ +package PerfumeOnMe.spring.domain.mapping; + +import PerfumeOnMe.spring.domain.Note; +import PerfumeOnMe.spring.domain.User; +import PerfumeOnMe.spring.domain.base.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class UserNote extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "note_id") + private Note note; + +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/UserTerms.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/UserTerms.java new file mode 100644 index 0000000..609ad96 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/UserTerms.java @@ -0,0 +1,29 @@ +package PerfumeOnMe.spring.domain.mapping; + +import PerfumeOnMe.spring.domain.Terms; +import PerfumeOnMe.spring.domain.User; +import PerfumeOnMe.spring.domain.base.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class UserTerms extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "terms_id") + private Terms terms; + + @Column(nullable = false) + private boolean agreement; +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/UserRepository.java b/src/main/java/PerfumeOnMe/spring/repository/UserRepository.java new file mode 100644 index 0000000..7ff683b --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/UserRepository.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.repository; + +public interface UserRepository { +} diff --git a/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java b/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java new file mode 100644 index 0000000..079d7b1 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.security; + +public class SecurityConfig { +} diff --git a/src/main/java/PerfumeOnMe/spring/service/UserService.java b/src/main/java/PerfumeOnMe/spring/service/UserService.java new file mode 100644 index 0000000..798ea46 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/UserService.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.service; + +public class UserService { +} diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUser.java b/src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUser.java new file mode 100644 index 0000000..b7bbfc6 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUser.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.validation.annotation; + +public @interface ExistUser { +} diff --git a/src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserValidator.java b/src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserValidator.java new file mode 100644 index 0000000..3da924c --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserValidator.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.validation.validator; + +public class ExistUserValidator { +} diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java new file mode 100644 index 0000000..da0cf37 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.web.controller; + +public class UserController { +} diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java new file mode 100644 index 0000000..73ae338 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.web.dto.user; + +public class UserRequestDTO { +} diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/user/UserResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/user/UserResponseDTO.java new file mode 100644 index 0000000..65a8aff --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/user/UserResponseDTO.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.web.dto.user; + +public class UserResponseDTO { +} From 1dadb5ab1c61d31499ab4557da8f73390aeae85e Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Mon, 30 Jun 2025 17:06:56 +0900 Subject: [PATCH 007/339] =?UTF-8?q?[Feature]=20=EC=97=94=ED=8B=B0=ED=8B=B0?= =?UTF-8?q?=20=EC=84=A4=EA=B3=84=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 6148 -> 6148 bytes .../PULL_REQUEST_TEMPLATE.md | 0 src/.DS_Store | Bin 6148 -> 6148 bytes src/main/.DS_Store | Bin 6148 -> 6148 bytes src/main/java/.DS_Store | Bin 6148 -> 6148 bytes src/main/java/PerfumeOnMe/.DS_Store | Bin 6148 -> 6148 bytes 6 files changed, 0 insertions(+), 0 deletions(-) rename .github/{ISSUE_TEMPLATE => }/PULL_REQUEST_TEMPLATE.md (100%) diff --git a/.DS_Store b/.DS_Store index 3c47f0fffc1a49ee516a728e62e446dbc1cc56f0..4e616c9a958af73d0528956e12643b2c97fc5cf1 100644 GIT binary patch delta 14 VcmZoMXffFEnvKzD^BXn^K>#WK1q%QG delta 16 XcmZoMXffFEnr$*WtHWk(_L~9#G-U<1 diff --git a/.github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md similarity index 100% rename from .github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE.md rename to .github/PULL_REQUEST_TEMPLATE.md diff --git a/src/.DS_Store b/src/.DS_Store index 99dc41154e05b8c05ec48df036bc54a32529de91..fc18281fa955f5b09e77b851c3877bc488a119e2 100644 GIT binary patch delta 14 VcmZoMXffDuoQctB^9d#!Q2;1P1mgez delta 14 VcmZoMXffDuoQctJ^9d#!Q2;1J1mXYy diff --git a/src/main/.DS_Store b/src/main/.DS_Store index 0a1b57d4d390bd3518e6165368710405d4a2f66a..fe4ae2f09f133b01eba7a3f49dc7ed513193fc95 100644 GIT binary patch delta 14 VcmZoMXffDuoQctB^9d#!Q2;1P1mgez delta 14 VcmZoMXffDuoQctJ^9d#!Q2;1J1mXYy diff --git a/src/main/java/.DS_Store b/src/main/java/.DS_Store index 058968f7c946a9582c326b41104f3e3bce56554d..525eba57597436acd1251420a5b2436cf78522a1 100644 GIT binary patch delta 14 VcmZoMXffFEn2FJ7^AjcoQ2;4^1pfd4 delta 14 VcmZoMXffFEn2FJF^AjcoQ2;4;1pWX3 diff --git a/src/main/java/PerfumeOnMe/.DS_Store b/src/main/java/PerfumeOnMe/.DS_Store index 788c0dd9b8188f3914e4288bd0a208dc04576427..45121ee5af353f5fd90c9e25d457ec720feedd43 100644 GIT binary patch delta 14 VcmZoMXffDuo{7 Date: Mon, 30 Jun 2025 17:15:23 +0900 Subject: [PATCH 008/339] =?UTF-8?q?[Feature]=20=EC=97=94=ED=8B=B0=ED=8B=B0?= =?UTF-8?q?=20=EC=84=A4=EA=B3=84=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/PerfumeOnMe/spring/domain/ChatMessage.java | 4 ++++ src/main/java/PerfumeOnMe/spring/domain/Fragrance.java | 4 ++++ .../java/PerfumeOnMe/spring/domain/FragranceKeyword.java | 4 ++++ src/main/java/PerfumeOnMe/spring/domain/FragrancePrice.java | 4 ++++ src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java | 4 ++++ src/main/java/PerfumeOnMe/spring/domain/Location.java | 4 ++++ src/main/java/PerfumeOnMe/spring/domain/Note.java | 4 ++++ src/main/java/PerfumeOnMe/spring/domain/PBTI.java | 4 ++++ src/main/java/PerfumeOnMe/spring/domain/Season.java | 4 ++++ src/main/java/PerfumeOnMe/spring/domain/Terms.java | 4 ++++ src/main/java/PerfumeOnMe/spring/domain/User.java | 4 ++++ src/main/java/PerfumeOnMe/spring/domain/Workshop.java | 4 ++++ src/main/java/PerfumeOnMe/spring/domain/mapping/Diary.java | 5 +++++ .../PerfumeOnMe/spring/domain/mapping/FragranceBaseNote.java | 4 ++++ .../PerfumeOnMe/spring/domain/mapping/FragranceLocation.java | 4 ++++ .../spring/domain/mapping/FragranceMiddleNote.java | 4 ++++ .../PerfumeOnMe/spring/domain/mapping/FragranceSeason.java | 4 ++++ .../PerfumeOnMe/spring/domain/mapping/FragranceTopNote.java | 4 ++++ .../spring/domain/mapping/RecommendedFragrance.java | 4 ++++ .../PerfumeOnMe/spring/domain/mapping/UserFragrance.java | 4 ++++ .../java/PerfumeOnMe/spring/domain/mapping/UserNote.java | 4 ++++ .../java/PerfumeOnMe/spring/domain/mapping/UserTerms.java | 4 ++++ 22 files changed, 89 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/domain/ChatMessage.java b/src/main/java/PerfumeOnMe/spring/domain/ChatMessage.java index 06f7fd0..5024f7e 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/ChatMessage.java +++ b/src/main/java/PerfumeOnMe/spring/domain/ChatMessage.java @@ -4,12 +4,16 @@ import PerfumeOnMe.spring.domain.enums.ChatSender; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; @Entity @Getter @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@DynamicInsert +@DynamicUpdate public class ChatMessage extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java b/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java index 38d0604..c41612a 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java @@ -5,6 +5,8 @@ import PerfumeOnMe.spring.domain.mapping.*; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; import java.util.ArrayList; import java.util.List; @@ -14,6 +16,8 @@ @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@DynamicInsert +@DynamicUpdate public class Fragrance extends BaseEntity { // ----- 필드 ----- diff --git a/src/main/java/PerfumeOnMe/spring/domain/FragranceKeyword.java b/src/main/java/PerfumeOnMe/spring/domain/FragranceKeyword.java index 2eaa54d..8486eed 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/FragranceKeyword.java +++ b/src/main/java/PerfumeOnMe/spring/domain/FragranceKeyword.java @@ -3,12 +3,16 @@ import PerfumeOnMe.spring.domain.base.BaseEntity; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; @Entity @Getter @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@DynamicInsert +@DynamicUpdate public class FragranceKeyword extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/PerfumeOnMe/spring/domain/FragrancePrice.java b/src/main/java/PerfumeOnMe/spring/domain/FragrancePrice.java index f7f1682..a73b191 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/FragrancePrice.java +++ b/src/main/java/PerfumeOnMe/spring/domain/FragrancePrice.java @@ -3,12 +3,16 @@ import PerfumeOnMe.spring.domain.base.BaseEntity; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; @Entity @Getter @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@DynamicInsert +@DynamicUpdate public class FragrancePrice extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java b/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java index cf92bbd..2cf7923 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java +++ b/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java @@ -4,6 +4,8 @@ import PerfumeOnMe.spring.domain.mapping.RecommendedFragrance; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; import java.util.ArrayList; import java.util.List; @@ -13,6 +15,8 @@ @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@DynamicInsert +@DynamicUpdate public class ImageKeyword extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/PerfumeOnMe/spring/domain/Location.java b/src/main/java/PerfumeOnMe/spring/domain/Location.java index 55f5b09..bbda983 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Location.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Location.java @@ -4,6 +4,8 @@ import PerfumeOnMe.spring.domain.mapping.FragranceLocation; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; import java.util.ArrayList; import java.util.List; @@ -13,6 +15,8 @@ @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@DynamicInsert +@DynamicUpdate public class Location extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/PerfumeOnMe/spring/domain/Note.java b/src/main/java/PerfumeOnMe/spring/domain/Note.java index b6ebcf8..aab2227 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Note.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Note.java @@ -7,6 +7,8 @@ import PerfumeOnMe.spring.domain.mapping.UserNote; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; import java.util.ArrayList; import java.util.List; @@ -16,6 +18,8 @@ @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@DynamicInsert +@DynamicUpdate public class Note extends BaseEntity { @Id diff --git a/src/main/java/PerfumeOnMe/spring/domain/PBTI.java b/src/main/java/PerfumeOnMe/spring/domain/PBTI.java index a16ff36..90336f2 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/PBTI.java +++ b/src/main/java/PerfumeOnMe/spring/domain/PBTI.java @@ -4,6 +4,8 @@ import PerfumeOnMe.spring.domain.mapping.RecommendedFragrance; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; import java.util.ArrayList; import java.util.List; @@ -13,6 +15,8 @@ @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@DynamicInsert +@DynamicUpdate public class PBTI extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/PerfumeOnMe/spring/domain/Season.java b/src/main/java/PerfumeOnMe/spring/domain/Season.java index 5624b55..9224a86 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Season.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Season.java @@ -4,6 +4,8 @@ import PerfumeOnMe.spring.domain.mapping.FragranceSeason; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; import java.util.ArrayList; import java.util.List; @@ -13,6 +15,8 @@ @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@DynamicInsert +@DynamicUpdate public class Season extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/PerfumeOnMe/spring/domain/Terms.java b/src/main/java/PerfumeOnMe/spring/domain/Terms.java index 9fb1f77..43b228d 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Terms.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Terms.java @@ -3,6 +3,8 @@ import PerfumeOnMe.spring.domain.mapping.UserTerms; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; import java.util.ArrayList; import java.util.List; @@ -12,6 +14,8 @@ @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@DynamicInsert +@DynamicUpdate public class Terms { @Id diff --git a/src/main/java/PerfumeOnMe/spring/domain/User.java b/src/main/java/PerfumeOnMe/spring/domain/User.java index 901deb6..e5791a7 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/User.java +++ b/src/main/java/PerfumeOnMe/spring/domain/User.java @@ -11,6 +11,8 @@ import PerfumeOnMe.spring.domain.mapping.UserTerms; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; import java.time.LocalDate; import java.util.ArrayList; @@ -21,6 +23,8 @@ @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@DynamicInsert +@DynamicUpdate public class User extends BaseEntity { @Id diff --git a/src/main/java/PerfumeOnMe/spring/domain/Workshop.java b/src/main/java/PerfumeOnMe/spring/domain/Workshop.java index d1b1e97..07b8361 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Workshop.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Workshop.java @@ -4,6 +4,8 @@ import PerfumeOnMe.spring.domain.mapping.RecommendedFragrance; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; import java.util.ArrayList; import java.util.List; @@ -13,6 +15,8 @@ @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@DynamicInsert +@DynamicUpdate public class Workshop extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/Diary.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/Diary.java index 71e454f..202281c 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/Diary.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/Diary.java @@ -5,6 +5,8 @@ import PerfumeOnMe.spring.domain.base.BaseEntity; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; import java.time.LocalDate; @@ -13,6 +15,9 @@ @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@DynamicInsert +@DynamicUpdate + public class Diary extends BaseEntity { @Id diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceBaseNote.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceBaseNote.java index 8c8f96f..a75b5e6 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceBaseNote.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceBaseNote.java @@ -5,12 +5,16 @@ import PerfumeOnMe.spring.domain.base.BaseEntity; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; @Entity @Getter @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@DynamicInsert +@DynamicUpdate public class FragranceBaseNote extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceLocation.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceLocation.java index 9637b07..506f232 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceLocation.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceLocation.java @@ -5,12 +5,16 @@ import PerfumeOnMe.spring.domain.base.BaseEntity; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; @Entity @Getter @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@DynamicInsert +@DynamicUpdate public class FragranceLocation extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceMiddleNote.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceMiddleNote.java index 299630c..abe659a 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceMiddleNote.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceMiddleNote.java @@ -5,12 +5,16 @@ import PerfumeOnMe.spring.domain.base.BaseEntity; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; @Entity @Getter @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@DynamicInsert +@DynamicUpdate public class FragranceMiddleNote extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceSeason.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceSeason.java index bad69af..77278e6 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceSeason.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceSeason.java @@ -5,12 +5,16 @@ import PerfumeOnMe.spring.domain.base.BaseEntity; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; @Entity @Getter @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@DynamicInsert +@DynamicUpdate public class FragranceSeason extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceTopNote.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceTopNote.java index 069d73c..869d87d 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceTopNote.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceTopNote.java @@ -6,12 +6,16 @@ import PerfumeOnMe.spring.domain.base.BaseEntity; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; @Entity @Getter @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@DynamicInsert +@DynamicUpdate public class FragranceTopNote extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/RecommendedFragrance.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/RecommendedFragrance.java index 236c0bc..be1baed 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/RecommendedFragrance.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/RecommendedFragrance.java @@ -7,6 +7,8 @@ import PerfumeOnMe.spring.domain.base.BaseEntity; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; import org.hibernate.jdbc.Work; @Entity @@ -14,6 +16,8 @@ @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@DynamicInsert +@DynamicUpdate public class RecommendedFragrance extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/UserFragrance.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/UserFragrance.java index dbe4ece..c9f3a65 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/UserFragrance.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/UserFragrance.java @@ -5,12 +5,16 @@ import PerfumeOnMe.spring.domain.base.BaseEntity; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; @Entity @Getter @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@DynamicInsert +@DynamicUpdate public class UserFragrance extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/UserNote.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/UserNote.java index 4f9e0c4..bf63dbf 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/UserNote.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/UserNote.java @@ -5,12 +5,16 @@ import PerfumeOnMe.spring.domain.base.BaseEntity; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; @Entity @Getter @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@DynamicInsert +@DynamicUpdate public class UserNote extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/UserTerms.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/UserTerms.java index 609ad96..8b1aeda 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/UserTerms.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/UserTerms.java @@ -5,12 +5,16 @@ import PerfumeOnMe.spring.domain.base.BaseEntity; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; @Entity @Getter @Builder @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor +@DynamicInsert +@DynamicUpdate public class UserTerms extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) From c2fa4a382bc1f3608354f98a4a165520c7a122cb Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 30 Jun 2025 17:20:22 +0900 Subject: [PATCH 009/339] =?UTF-8?q?[Style]=20PR=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/{ISSUE_TEMPLATE => }/PULL_REQUEST_TEMPLATE.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/{ISSUE_TEMPLATE => }/PULL_REQUEST_TEMPLATE.md (100%) diff --git a/.github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md similarity index 100% rename from .github/ISSUE_TEMPLATE/PULL_REQUEST_TEMPLATE.md rename to .github/PULL_REQUEST_TEMPLATE.md From 7cf7385ceca3cc47ed9e9f97b7425d8bec482f72 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Mon, 30 Jun 2025 22:15:18 +0900 Subject: [PATCH 010/339] =?UTF-8?q?[Feature]=20=EC=97=94=ED=8B=B0=ED=8B=B0?= =?UTF-8?q?=20=EC=84=A4=EA=B3=84=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/domain/Fragrance.java | 10 +++++++++ .../spring/domain/ImageKeyword.java | 1 + .../PerfumeOnMe/spring/domain/Location.java | 1 + .../java/PerfumeOnMe/spring/domain/Note.java | 4 ++++ .../java/PerfumeOnMe/spring/domain/PBTI.java | 1 + .../PerfumeOnMe/spring/domain/Season.java | 1 + .../java/PerfumeOnMe/spring/domain/Terms.java | 1 + .../java/PerfumeOnMe/spring/domain/User.java | 10 +++++++++ .../PerfumeOnMe/spring/domain/Workshop.java | 1 + .../spring/security/SecurityConfig.java | 22 +++++++++++++++++++ src/main/resources/application.properties | 1 - 11 files changed, 52 insertions(+), 1 deletion(-) delete mode 100644 src/main/resources/application.properties diff --git a/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java b/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java index c41612a..049983d 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java @@ -70,33 +70,43 @@ public class Fragrance extends BaseEntity { //----- 매핑 관계 ----- @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + @Builder.Default private List userFragranceList = new ArrayList<>(); @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + @Builder.Default private List diaryList = new ArrayList<>(); @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + @Builder.Default private List fragranceSeasonList = new ArrayList<>(); @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + @Builder.Default private List fragranceLocationList = new ArrayList<>(); @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + @Builder.Default private List fragranceKeywordList = new ArrayList<>(); @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + @Builder.Default private List fragrancePriceList = new ArrayList<>(); @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + @Builder.Default private List fragranceTopNoteList = new ArrayList<>(); @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + @Builder.Default private List fragranceMiddleNoteList = new ArrayList<>(); @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + @Builder.Default private List fragranceBaseNoteList = new ArrayList<>(); @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + @Builder.Default private List RecommendedFragranceList = new ArrayList<>(); } diff --git a/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java b/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java index 2cf7923..0160ffe 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java +++ b/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java @@ -27,5 +27,6 @@ public class ImageKeyword extends BaseEntity { private User user; @OneToMany(mappedBy = "imageKeyword", cascade = CascadeType.ALL) + @Builder.Default private List RecommendedFragranceList = new ArrayList<>(); } diff --git a/src/main/java/PerfumeOnMe/spring/domain/Location.java b/src/main/java/PerfumeOnMe/spring/domain/Location.java index bbda983..8e4902a 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Location.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Location.java @@ -26,6 +26,7 @@ public class Location extends BaseEntity { private String name; @OneToMany(mappedBy = "location", cascade = CascadeType.ALL) + @Builder.Default private List fragranceLocationList = new ArrayList<>(); diff --git a/src/main/java/PerfumeOnMe/spring/domain/Note.java b/src/main/java/PerfumeOnMe/spring/domain/Note.java index aab2227..448754a 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Note.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Note.java @@ -42,14 +42,18 @@ public class Note extends BaseEntity { private boolean base; @OneToMany(mappedBy = "note", cascade = CascadeType.ALL) + @Builder.Default private List fragranceTopNoteList = new ArrayList<>(); @OneToMany(mappedBy = "note", cascade = CascadeType.ALL) + @Builder.Default private List fragranceMiddleNoteList = new ArrayList<>(); @OneToMany(mappedBy = "note", cascade = CascadeType.ALL) + @Builder.Default private List fragranceBaseNoteList = new ArrayList<>(); @OneToMany(mappedBy = "note", cascade = CascadeType.ALL) + @Builder.Default private List userNoteList = new ArrayList<>(); } diff --git a/src/main/java/PerfumeOnMe/spring/domain/PBTI.java b/src/main/java/PerfumeOnMe/spring/domain/PBTI.java index 90336f2..d83675e 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/PBTI.java +++ b/src/main/java/PerfumeOnMe/spring/domain/PBTI.java @@ -27,5 +27,6 @@ public class PBTI extends BaseEntity { private User user; @OneToMany(mappedBy = "pbti", cascade = CascadeType.ALL) + @Builder.Default private List RecommendedFragranceList = new ArrayList<>(); } diff --git a/src/main/java/PerfumeOnMe/spring/domain/Season.java b/src/main/java/PerfumeOnMe/spring/domain/Season.java index 9224a86..9a950e9 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Season.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Season.java @@ -26,5 +26,6 @@ public class Season extends BaseEntity { private String name; @OneToMany(mappedBy = "season", cascade = CascadeType.ALL) + @Builder.Default private List fragranceSeasonList = new ArrayList<>(); } diff --git a/src/main/java/PerfumeOnMe/spring/domain/Terms.java b/src/main/java/PerfumeOnMe/spring/domain/Terms.java index 43b228d..3b331b8 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Terms.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Terms.java @@ -32,5 +32,6 @@ public class Terms { private boolean required; @OneToMany(mappedBy = "terms", cascade = CascadeType.ALL) + @Builder.Default private List userTermsList = new ArrayList<>(); } diff --git a/src/main/java/PerfumeOnMe/spring/domain/User.java b/src/main/java/PerfumeOnMe/spring/domain/User.java index e5791a7..d34e39d 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/User.java +++ b/src/main/java/PerfumeOnMe/spring/domain/User.java @@ -27,6 +27,7 @@ @DynamicUpdate public class User extends BaseEntity { + // ----- 필드 ----- @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -67,27 +68,36 @@ public class User extends BaseEntity { @Column(columnDefinition = "TEXT") private String imageURL; + // ----- 매핑 ----- @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + @Builder.Default private List userFragranceList = new ArrayList<>(); @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + @Builder.Default private List userTermsList = new ArrayList<>(); @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + @Builder.Default private List diaryList = new ArrayList<>(); @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + @Builder.Default private List chatMessageList = new ArrayList<>(); @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + @Builder.Default private List userNoteList = new ArrayList<>(); @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + @Builder.Default private List pbtiList = new ArrayList<>(); @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + @Builder.Default private List imageKeywordList = new ArrayList<>(); @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + @Builder.Default private List workshopList = new ArrayList<>(); } diff --git a/src/main/java/PerfumeOnMe/spring/domain/Workshop.java b/src/main/java/PerfumeOnMe/spring/domain/Workshop.java index 07b8361..0799d1d 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Workshop.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Workshop.java @@ -27,5 +27,6 @@ public class Workshop extends BaseEntity { private User user; @OneToMany(mappedBy = "workshop", cascade = CascadeType.ALL) + @Builder.Default private List RecommendedFragranceList = new ArrayList<>(); } diff --git a/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java b/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java index 079d7b1..ce485ab 100644 --- a/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java +++ b/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java @@ -1,4 +1,26 @@ package PerfumeOnMe.spring.security; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(auth -> auth + .anyRequest().permitAll() + ) + .formLogin(config -> config.disable()) + .httpBasic(config -> config.disable()) + .csrf(config -> config.disable()); + + return http.build(); + } } + diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 0c7c661..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=spring From 3f8e44fe88dfe56031dafd765d470cdf4e17242b Mon Sep 17 00:00:00 2001 From: chanudevelop <“chanu.develop@gmail.com”> Date: Tue, 1 Jul 2025 12:30:39 +0900 Subject: [PATCH 011/339] =?UTF-8?q?[CI/CD]=20workflows=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev_deploy.yml | 61 ++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 .github/workflows/dev_deploy.yml diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml new file mode 100644 index 0000000..ace97d0 --- /dev/null +++ b/.github/workflows/dev_deploy.yml @@ -0,0 +1,61 @@ +name: Perfume on Me Dev CI/CD + +on: + pull_request: + types: [closed] + workflow_dispatch: # (2).수동 실행도 가능하도록 + +jobs: + build: + runs-on: ubuntu-latest # (3). CI/CD 작업을 수행할 OS환경 / 하단의 develop을 release로 바꾸면 배포 스크립트 + if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'develop' + + steps: + - name: Checkout + uses: actions/checkout@v2 # (4).코드 check out + + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: 21 # (5).자바 설치 + distribution: 'adopt' + + - name: Grant execute permission for gradlew + run: chmod +x ./gradlew + shell: bash # (6).권한 부여 + + - name: Build with Gradle + run: ./gradlew clean build -x test + shell: bash # (7).build시작 + + - name: Get current time + uses: 1466587594/get-current-time@v2 + id: current-time + with: + format: YYYY-MM-DDTHH-mm-ss + utcOffset: "+09:00" # (8).build시점의 시간확보 + + - name: Show Current Time + run: echo "CurrentTime=${{ steps.current-time.outputs.formattedTime }}" + shell: bash # (9).확보한 시간 보여주기 + + - name: Generate deployment package + run: | + mkdir -p deploy + cp build/libs/*.jar deploy/application.jar + cp Procfile deploy/Procfile + cp -r .ebextensions_dev deploy/.ebextensions + cp -r .platform deploy/.platform + cd deploy && zip -r deploy.zip . + + - name: Beanstalk Deploy + uses: einaregilsson/beanstalk-deploy@v20 + with: + aws_access_key: ${{ secrets.AWS_ACTION_ACCESS_KEY_ID }} + aws_secret_key: ${{ secrets.AWS_ACTION_SECRET_ACCESS_KEY }} + application_name: umc-dev + environment_name: Umc-dev-env + version_label: github-action-${{ steps.current-time.outputs.formattedTime }} + region: ap-northeast-2 + deployment_package: deploy/deploy.zip + wait_for_deployment: false \ No newline at end of file From 27ad9a480a60ed14996a3496e579983caeb8ca1c Mon Sep 17 00:00:00 2001 From: chanudevelop <“chanu.develop@gmail.com”> Date: Tue, 1 Jul 2025 12:32:44 +0900 Subject: [PATCH 012/339] =?UTF-8?q?[CI/CD]=20dev=5Fdeploy.yml=20=EB=A6=AC?= =?UTF-8?q?=EC=A0=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev_deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index ace97d0..010d50d 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -56,6 +56,6 @@ jobs: application_name: umc-dev environment_name: Umc-dev-env version_label: github-action-${{ steps.current-time.outputs.formattedTime }} - region: ap-northeast-2 + region: ap-northeast-1 deployment_package: deploy/deploy.zip wait_for_deployment: false \ No newline at end of file From 87db2118e8642bfcffd8f7f9b9b0fe13b349bbc3 Mon Sep 17 00:00:00 2001 From: chanudevelop <“chanu.develop@gmail.com”> Date: Tue, 1 Jul 2025 12:34:42 +0900 Subject: [PATCH 013/339] =?UTF-8?q?[CI/CD]=20health=20check=20api=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20(#6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/web/controller/RootController.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/web/controller/RootController.java diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/RootController.java b/src/main/java/PerfumeOnMe/spring/web/controller/RootController.java new file mode 100644 index 0000000..2654d7c --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/controller/RootController.java @@ -0,0 +1,13 @@ +package PerfumeOnMe.spring.web.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class RootController { + + @GetMapping("/health") + public String healthCheck(){ + return "I'm really healthy"; + } +} \ No newline at end of file From fb8e24098d612bb29287d8b8408b28a7c537c27c Mon Sep 17 00:00:00 2001 From: chanudevelop <“chanu.develop@gmail.com”> Date: Tue, 1 Jul 2025 12:36:01 +0900 Subject: [PATCH 014/339] =?UTF-8?q?[CI/CD]=20Procfile=EC=83=9D=EC=84=B1(#6?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Procfile | 1 + 1 file changed, 1 insertion(+) create mode 100644 Procfile diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..58dab8d --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: appstart \ No newline at end of file From 92377e8bb67a13225283a2a5cec24832c1502ac7 Mon Sep 17 00:00:00 2001 From: chanudevelop <“chanu.develop@gmail.com”> Date: Tue, 1 Jul 2025 12:59:31 +0900 Subject: [PATCH 015/339] =?UTF-8?q?[CI/CD]=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20=EC=9E=91=EC=84=B1=20=EB=B0=8F=20ELB=EC=84=A4?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ebextensions_dev/00-makeFiles.config | 12 +++++ .ebextensions_dev/01-set-timezone.config | 3 ++ .github/workflows/dev_deploy.yml | 4 +- .platform/conf.d/client_max_body_size.conf | 1 + .platform/nginx.conf | 63 ++++++++++++++++++++++ build.gradle | 4 ++ 6 files changed, 85 insertions(+), 2 deletions(-) create mode 100644 .ebextensions_dev/00-makeFiles.config create mode 100644 .ebextensions_dev/01-set-timezone.config create mode 100644 .platform/conf.d/client_max_body_size.conf create mode 100644 .platform/nginx.conf diff --git a/.ebextensions_dev/00-makeFiles.config b/.ebextensions_dev/00-makeFiles.config new file mode 100644 index 0000000..8030404 --- /dev/null +++ b/.ebextensions_dev/00-makeFiles.config @@ -0,0 +1,12 @@ +files: + "/sbin/appstart": + mode: "000755" + owner: webapp + group: webapp + content: | + #!/usr/bin/env bash + JAR_PATH=/var/app/current/application.jar + + # run app + killalljava + java -Dfile.encoding=UTF-8 -jar $JAR_PATH \ No newline at end of file diff --git a/.ebextensions_dev/01-set-timezone.config b/.ebextensions_dev/01-set-timezone.config new file mode 100644 index 0000000..869275c --- /dev/null +++ b/.ebextensions_dev/01-set-timezone.config @@ -0,0 +1,3 @@ +commands: + set_time_zone: + command: ln -f -s /usr/share/zoneinfo/Asia/Seoul /etc/localtime \ No newline at end of file diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index 010d50d..493e301 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -53,8 +53,8 @@ jobs: with: aws_access_key: ${{ secrets.AWS_ACTION_ACCESS_KEY_ID }} aws_secret_key: ${{ secrets.AWS_ACTION_SECRET_ACCESS_KEY }} - application_name: umc-dev - environment_name: Umc-dev-env + application_name: perfume-dev-app + environment_name: Perfume-dev-app-env version_label: github-action-${{ steps.current-time.outputs.formattedTime }} region: ap-northeast-1 deployment_package: deploy/deploy.zip diff --git a/.platform/conf.d/client_max_body_size.conf b/.platform/conf.d/client_max_body_size.conf new file mode 100644 index 0000000..8e8277e --- /dev/null +++ b/.platform/conf.d/client_max_body_size.conf @@ -0,0 +1 @@ +client_max_body_size 200M; \ No newline at end of file diff --git a/.platform/nginx.conf b/.platform/nginx.conf new file mode 100644 index 0000000..612092e --- /dev/null +++ b/.platform/nginx.conf @@ -0,0 +1,63 @@ +user nginx; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; +worker_processes auto; +worker_rlimit_nofile 33282; + +events { + use epoll; + worker_connections 1024; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + include conf.d/*.conf; + + map $http_upgrade $connection_upgrade { + default "upgrade"; + } + + upstream springboot { + server 127.0.0.1:8080; + keepalive 1024; + } + + server { + listen 80 default_server; + listen [::]:80 default_server; + + location / { + proxy_pass http://springboot; + # CORS 관련 헤더 추가 + add_header 'Access-Control-Allow-Origin' '*'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS'; + add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type'; + proxy_http_version 1.1; + proxy_set_header Connection $connection_upgrade; + proxy_set_header Upgrade $http_upgrade; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + + access_log /var/log/nginx/access.log main; + + client_header_timeout 60; + client_body_timeout 60; + keepalive_timeout 60; + gzip off; + gzip_comp_level 4; + + # Include the Elastic Beanstalk generated locations + include conf.d/elasticbeanstalk/healthd.conf; + } +} \ No newline at end of file diff --git a/build.gradle b/build.gradle index 83c581f..9ed9f1f 100644 --- a/build.gradle +++ b/build.gradle @@ -108,3 +108,7 @@ tasks.register("compileQuerydsl", JavaCompile) { compileJava { dependsOn compileQuerydsl } + +jar { + enabled = false +} \ No newline at end of file From 4c4218deb2a4e2ca4b437977942d04ddfc0595b2 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Tue, 1 Jul 2025 19:54:05 +0900 Subject: [PATCH 016/339] =?UTF-8?q?[Fix]=20=EC=BD=94=EB=93=9C=20=EC=BB=A8?= =?UTF-8?q?=EB=B2=A4=EC=85=98=20=EB=B0=98=EC=98=81=20=EB=B0=8F=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=EC=A0=9D=ED=8A=B8=20=EA=B5=AC=EC=A1=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 140 +++++++-------- .../spring/config/QueryDSLConfig.java | 21 +++ .../{ => config}/security/SecurityConfig.java | 2 +- .../spring/domain/ChatMessage.java | 46 +++-- .../PerfumeOnMe/spring/domain/Fragrance.java | 166 ++++++++++-------- .../spring/domain/FragranceKeyword.java | 36 ++-- .../spring/domain/FragrancePrice.java | 40 +++-- .../spring/domain/ImageKeyword.java | 45 +++-- .../PerfumeOnMe/spring/domain/Location.java | 42 +++-- .../java/PerfumeOnMe/spring/domain/Note.java | 77 ++++---- .../java/PerfumeOnMe/spring/domain/PBTI.java | 45 +++-- .../PerfumeOnMe/spring/domain/Season.java | 41 +++-- .../java/PerfumeOnMe/spring/domain/Terms.java | 47 +++-- .../java/PerfumeOnMe/spring/domain/User.java | 139 ++++++++------- .../PerfumeOnMe/spring/domain/Workshop.java | 45 +++-- .../spring/domain/mapping/Diary.java | 53 +++--- .../domain/mapping/FragranceBaseNote.java | 39 ++-- .../domain/mapping/FragranceLocation.java | 39 ++-- .../domain/mapping/FragranceMiddleNote.java | 39 ++-- .../domain/mapping/FragranceSeason.java | 39 ++-- .../domain/mapping/FragranceTopNote.java | 42 +++-- .../domain/mapping/RecommendedFragrance.java | 57 +++--- .../spring/domain/mapping/UserFragrance.java | 39 ++-- .../spring/domain/mapping/UserNote.java | 39 ++-- .../spring/domain/mapping/UserTerms.java | 44 +++-- .../spring/repository/UserRepository.java | 4 - .../repository/user/UserRepository.java | 8 + .../repository/user/UserRepositoryCustom.java | 5 + .../repository/user/UserRepositoryImpl.java | 10 ++ .../spring/service/UserService.java | 4 - .../spring/service/user/UserService.java | 5 + .../spring/service/user/UserServiceImpl.java | 13 ++ .../validation/annotation/ExistUser.java | 13 ++ .../validator/ExistUserValidator.java | 17 +- .../spring/web/controller/UserController.java | 12 ++ 35 files changed, 928 insertions(+), 525 deletions(-) create mode 100644 src/main/java/PerfumeOnMe/spring/config/QueryDSLConfig.java rename src/main/java/PerfumeOnMe/spring/{ => config}/security/SecurityConfig.java (95%) delete mode 100644 src/main/java/PerfumeOnMe/spring/repository/UserRepository.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/user/UserRepository.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/user/UserRepositoryCustom.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/user/UserRepositoryImpl.java delete mode 100644 src/main/java/PerfumeOnMe/spring/service/UserService.java create mode 100644 src/main/java/PerfumeOnMe/spring/service/user/UserService.java create mode 100644 src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java diff --git a/build.gradle b/build.gradle index 83c581f..ca9269d 100644 --- a/build.gradle +++ b/build.gradle @@ -1,110 +1,110 @@ plugins { - id 'java' - id 'org.springframework.boot' version '3.5.3' - id 'io.spring.dependency-management' version '1.1.7' + id 'java' + id 'org.springframework.boot' version '3.5.3' + id 'io.spring.dependency-management' version '1.1.7' } group = 'PerfumeOnMe' version = '0.0.1-SNAPSHOT' java { - toolchain { - languageVersion = JavaLanguageVersion.of(21) - } + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } } configurations { - compileOnly { - extendsFrom annotationProcessor - } - querydsl { } + compileOnly { + extendsFrom annotationProcessor + } + querydsl {} } repositories { - mavenCentral() + mavenCentral() } ext { - querydslVersion = '5.1.0' + querydslVersion = '5.1.0' } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-web' - implementation 'org.springframework.boot:spring-boot-starter-validation' - - // Lombok - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' - - // MySQL Driver -// runtimeOnly 'com.mysql:mysql-connector-j' - - // ✅ QueryDSL (5.1.0) - implementation "com.querydsl:querydsl-jpa:$querydslVersion:jakarta" - annotationProcessor "com.querydsl:querydsl-apt:$querydslVersion:jakarta" - annotationProcessor "jakarta.annotation:jakarta.annotation-api" - annotationProcessor "jakarta.persistence:jakarta.persistence-api" - - // 테스트 관련 의존성 - testImplementation 'org.springframework.boot:spring-boot-starter-test' - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - - // Swagger - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' - // validation - implementation 'org.springframework.boot:spring-boot-starter-validation' - // Security - implementation 'org.springframework.boot:spring-boot-starter-security' - testImplementation 'org.springframework.security:spring-security-test' - // Thymeleaf - implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' - implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.1.RELEASE' - // JWT - implementation 'io.jsonwebtoken:jjwt-api:0.11.5' - runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // MySQL Driver + runtimeOnly 'com.mysql:mysql-connector-j' + + // ✅ QueryDSL (5.1.0) + implementation "com.querydsl:querydsl-jpa:$querydslVersion:jakarta" + annotationProcessor "com.querydsl:querydsl-apt:$querydslVersion:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + + // 테스트 관련 의존성 + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' + // validation + implementation 'org.springframework.boot:spring-boot-starter-validation' + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + // Thymeleaf + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6:3.1.1.RELEASE' + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' } tasks.named('test') { - useJUnitPlatform() + useJUnitPlatform() } // QueryDSL 설정부 def querydslDir = file("src/main/generated") sourceSets { - main { - java { - srcDirs += querydslDir - } - } + main { + java { + srcDirs += querydslDir + } + } } // Q 클래스 중복 생성 방지 및 자동 생성 tasks.named('compileJava') { - doFirst { - if (querydslDir.exists()) { - querydslDir.deleteDir() // Q 클래스 중복 생성 방지 - } - querydslDir.mkdirs() - } + doFirst { + if (querydslDir.exists()) { + querydslDir.deleteDir() // Q 클래스 중복 생성 방지 + } + querydslDir.mkdirs() + } } tasks.register("compileQuerydsl", JavaCompile) { - group = "build" - description = "Generates the QueryDSL Q-types" - source = sourceSets.main.java - destinationDirectory = querydslDir - classpath = sourceSets.main.compileClasspath - options.annotationProcessorPath = configurations.annotationProcessor - options.compilerArgs = [ - '-proc:only', - '-processor', 'com.querydsl.apt.jpa.JPAAnnotationProcessor' - ] + group = "build" + description = "Generates the QueryDSL Q-types" + source = sourceSets.main.java + destinationDirectory = querydslDir + classpath = sourceSets.main.compileClasspath + options.annotationProcessorPath = configurations.annotationProcessor + options.compilerArgs = [ + '-proc:only', + '-processor', 'com.querydsl.apt.jpa.JPAAnnotationProcessor' + ] } // compileJava는 항상 compileQuerydsl 이후에 실행 compileJava { - dependsOn compileQuerydsl + dependsOn compileQuerydsl } diff --git a/src/main/java/PerfumeOnMe/spring/config/QueryDSLConfig.java b/src/main/java/PerfumeOnMe/spring/config/QueryDSLConfig.java new file mode 100644 index 0000000..099ceb2 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/QueryDSLConfig.java @@ -0,0 +1,21 @@ +package PerfumeOnMe.spring.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +import jakarta.persistence.EntityManager; +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class QueryDSLConfig { + + private final EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java b/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java similarity index 95% rename from src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java rename to src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java index ce485ab..d44e133 100644 --- a/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.security; +package PerfumeOnMe.spring.config.security; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/PerfumeOnMe/spring/domain/ChatMessage.java b/src/main/java/PerfumeOnMe/spring/domain/ChatMessage.java index 5024f7e..1ebf275 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/ChatMessage.java +++ b/src/main/java/PerfumeOnMe/spring/domain/ChatMessage.java @@ -1,12 +1,27 @@ package PerfumeOnMe.spring.domain; -import PerfumeOnMe.spring.domain.base.BaseEntity; -import PerfumeOnMe.spring.domain.enums.ChatSender; -import jakarta.persistence.*; -import lombok.*; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; +import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.domain.enums.ChatSender; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + @Entity @Getter @Builder @@ -14,19 +29,20 @@ @AllArgsConstructor @DynamicInsert @DynamicUpdate +@Table(name = "chat_messages") public class ChatMessage extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - private User user; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; - @Column(columnDefinition = "TEXT", nullable = false) - private String content; + @Column(columnDefinition = "TEXT", nullable = false) + private String content; - @Enumerated(EnumType.STRING) - @Column(columnDefinition = "VARCHAR(10)", nullable = false) - private ChatSender senderType; + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "VARCHAR(10)", nullable = false) + private ChatSender senderType; } diff --git a/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java b/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java index 049983d..fabe557 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java @@ -1,15 +1,38 @@ package PerfumeOnMe.spring.domain; -import PerfumeOnMe.spring.domain.base.BaseEntity; -import PerfumeOnMe.spring.domain.enums.*; -import PerfumeOnMe.spring.domain.mapping.*; -import jakarta.persistence.*; -import lombok.*; +import java.util.ArrayList; +import java.util.List; + import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import java.util.ArrayList; -import java.util.List; +import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.domain.enums.Brand; +import PerfumeOnMe.spring.domain.enums.FragranceGender; +import PerfumeOnMe.spring.domain.enums.FragranceType; +import PerfumeOnMe.spring.domain.mapping.Diary; +import PerfumeOnMe.spring.domain.mapping.FragranceBaseNote; +import PerfumeOnMe.spring.domain.mapping.FragranceLocation; +import PerfumeOnMe.spring.domain.mapping.FragranceMiddleNote; +import PerfumeOnMe.spring.domain.mapping.FragranceSeason; +import PerfumeOnMe.spring.domain.mapping.FragranceTopNote; +import PerfumeOnMe.spring.domain.mapping.RecommendedFragrance; +import PerfumeOnMe.spring.domain.mapping.UserFragrance; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter @@ -18,95 +41,96 @@ @AllArgsConstructor @DynamicInsert @DynamicUpdate +@Table(name = "fragrances") public class Fragrance extends BaseEntity { - // ----- 필드 ----- - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + // ----- 필드 ----- + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(nullable = false, unique = true, length = 30) - private String name; + @Column(nullable = false, unique = true, length = 30) + private String name; - @Enumerated(EnumType.STRING) - @Column(columnDefinition = "VARCHAR(15)") - private Brand brand; + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "VARCHAR(15)") + private Brand brand; - @Column(nullable = false) - private String description; + @Column(nullable = false) + private String description; - @Enumerated(EnumType.STRING) - @Column(columnDefinition = "VARCHAR(10)") - private FragranceGender gender; + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "VARCHAR(10)") + private FragranceGender gender; - @Column(columnDefinition = "TEXT", nullable = false) - private String homePageURL; + @Column(columnDefinition = "TEXT", nullable = false) + private String homePageURL; - @Enumerated(EnumType.STRING) - @Column(columnDefinition = "VARCHAR(30)", nullable = false) - private FragranceType fragranceType; + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "VARCHAR(30)", nullable = false) + private FragranceType fragranceType; - @Column(columnDefinition = "TEXT", nullable = false) - private String ImageURL; + @Column(columnDefinition = "TEXT", nullable = false) + private String ImageURL; - @Column(nullable = false) - private String topNoteDescription; + @Column(nullable = false) + private String topNoteDescription; - @Column(nullable = false) - private String middleNoteDescription; + @Column(nullable = false) + private String middleNoteDescription; - @Column(nullable = false) - private String baseNoteDescription; + @Column(nullable = false) + private String baseNoteDescription; - @Column(nullable = false) - private String topNoteKeyword; + @Column(nullable = false) + private String topNoteKeyword; - @Column(nullable = false) - private String middleNoteKeyword; + @Column(nullable = false) + private String middleNoteKeyword; - @Column(nullable = false) - private String baseNoteKeyword; + @Column(nullable = false) + private String baseNoteKeyword; - //----- 매핑 관계 ----- + //----- 매핑 관계 ----- - @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) - @Builder.Default - private List userFragranceList = new ArrayList<>(); + @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + @Builder.Default + private List userFragranceList = new ArrayList<>(); - @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) - @Builder.Default - private List diaryList = new ArrayList<>(); + @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + @Builder.Default + private List diaryList = new ArrayList<>(); - @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) - @Builder.Default - private List fragranceSeasonList = new ArrayList<>(); + @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + @Builder.Default + private List fragranceSeasonList = new ArrayList<>(); - @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) - @Builder.Default - private List fragranceLocationList = new ArrayList<>(); + @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + @Builder.Default + private List fragranceLocationList = new ArrayList<>(); - @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) - @Builder.Default - private List fragranceKeywordList = new ArrayList<>(); + @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + @Builder.Default + private List fragranceKeywordList = new ArrayList<>(); - @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) - @Builder.Default - private List fragrancePriceList = new ArrayList<>(); + @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + @Builder.Default + private List fragrancePriceList = new ArrayList<>(); - @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) - @Builder.Default - private List fragranceTopNoteList = new ArrayList<>(); + @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + @Builder.Default + private List fragranceTopNoteList = new ArrayList<>(); - @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) - @Builder.Default - private List fragranceMiddleNoteList = new ArrayList<>(); + @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + @Builder.Default + private List fragranceMiddleNoteList = new ArrayList<>(); - @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) - @Builder.Default - private List fragranceBaseNoteList = new ArrayList<>(); + @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + @Builder.Default + private List fragranceBaseNoteList = new ArrayList<>(); - @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) - @Builder.Default - private List RecommendedFragranceList = new ArrayList<>(); + @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + @Builder.Default + private List RecommendedFragranceList = new ArrayList<>(); } diff --git a/src/main/java/PerfumeOnMe/spring/domain/FragranceKeyword.java b/src/main/java/PerfumeOnMe/spring/domain/FragranceKeyword.java index 8486eed..96be4ba 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/FragranceKeyword.java +++ b/src/main/java/PerfumeOnMe/spring/domain/FragranceKeyword.java @@ -1,11 +1,24 @@ package PerfumeOnMe.spring.domain; -import PerfumeOnMe.spring.domain.base.BaseEntity; -import jakarta.persistence.*; -import lombok.*; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; +import PerfumeOnMe.spring.domain.base.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + @Entity @Getter @Builder @@ -13,16 +26,17 @@ @AllArgsConstructor @DynamicInsert @DynamicUpdate +@Table(name = "fragrance_keywords") public class FragranceKeyword extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(nullable = false, unique = true, length = 20) - private String name; + @Column(nullable = false, unique = true, length = 20) + private String name; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "fragrance_id") - private Fragrance fragrance; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fragrance_id") + private Fragrance fragrance; } diff --git a/src/main/java/PerfumeOnMe/spring/domain/FragrancePrice.java b/src/main/java/PerfumeOnMe/spring/domain/FragrancePrice.java index a73b191..0624ec3 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/FragrancePrice.java +++ b/src/main/java/PerfumeOnMe/spring/domain/FragrancePrice.java @@ -1,11 +1,24 @@ package PerfumeOnMe.spring.domain; -import PerfumeOnMe.spring.domain.base.BaseEntity; -import jakarta.persistence.*; -import lombok.*; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; +import PerfumeOnMe.spring.domain.base.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + @Entity @Getter @Builder @@ -13,18 +26,19 @@ @AllArgsConstructor @DynamicInsert @DynamicUpdate +@Table(name = "fragrance_prices") public class FragrancePrice extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(nullable = false) - private int mlCount; + @Column(nullable = false) + private int mlCount; - @Column(nullable = false) - private int price; + @Column(nullable = false) + private int price; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "fragrance_id") - private Fragrance fragrance; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fragrance_id") + private Fragrance fragrance; } diff --git a/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java b/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java index 0160ffe..5aca056 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java +++ b/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java @@ -1,14 +1,28 @@ package PerfumeOnMe.spring.domain; -import PerfumeOnMe.spring.domain.base.BaseEntity; -import PerfumeOnMe.spring.domain.mapping.RecommendedFragrance; -import jakarta.persistence.*; -import lombok.*; +import java.util.ArrayList; +import java.util.List; + import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import java.util.ArrayList; -import java.util.List; +import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.domain.mapping.RecommendedFragrance; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter @@ -17,16 +31,17 @@ @AllArgsConstructor @DynamicInsert @DynamicUpdate +@Table(name = "image_keywords") public class ImageKeyword extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - private User user; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; - @OneToMany(mappedBy = "imageKeyword", cascade = CascadeType.ALL) - @Builder.Default - private List RecommendedFragranceList = new ArrayList<>(); + @OneToMany(mappedBy = "imageKeyword", cascade = CascadeType.ALL) + @Builder.Default + private List RecommendedFragranceList = new ArrayList<>(); } diff --git a/src/main/java/PerfumeOnMe/spring/domain/Location.java b/src/main/java/PerfumeOnMe/spring/domain/Location.java index 8e4902a..eb56eb2 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Location.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Location.java @@ -1,14 +1,26 @@ package PerfumeOnMe.spring.domain; -import PerfumeOnMe.spring.domain.base.BaseEntity; -import PerfumeOnMe.spring.domain.mapping.FragranceLocation; -import jakarta.persistence.*; -import lombok.*; +import java.util.ArrayList; +import java.util.List; + import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import java.util.ArrayList; -import java.util.List; +import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.domain.mapping.FragranceLocation; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter @@ -17,17 +29,17 @@ @AllArgsConstructor @DynamicInsert @DynamicUpdate +@Table(name = "locations") public class Location extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, unique = true, length = 15) - private String name; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @OneToMany(mappedBy = "location", cascade = CascadeType.ALL) - @Builder.Default - private List fragranceLocationList = new ArrayList<>(); + @Column(nullable = false, unique = true, length = 15) + private String name; + @OneToMany(mappedBy = "location", cascade = CascadeType.ALL) + @Builder.Default + private List fragranceLocationList = new ArrayList<>(); } diff --git a/src/main/java/PerfumeOnMe/spring/domain/Note.java b/src/main/java/PerfumeOnMe/spring/domain/Note.java index 448754a..fef293a 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Note.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Note.java @@ -1,17 +1,29 @@ package PerfumeOnMe.spring.domain; +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + import PerfumeOnMe.spring.domain.base.BaseEntity; import PerfumeOnMe.spring.domain.mapping.FragranceBaseNote; import PerfumeOnMe.spring.domain.mapping.FragranceMiddleNote; import PerfumeOnMe.spring.domain.mapping.FragranceTopNote; import PerfumeOnMe.spring.domain.mapping.UserNote; -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.DynamicInsert; -import org.hibernate.annotations.DynamicUpdate; - -import java.util.ArrayList; -import java.util.List; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter @@ -20,40 +32,41 @@ @AllArgsConstructor @DynamicInsert @DynamicUpdate +@Table(name = "notes") public class Note extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(nullable = false, unique = true, length = 40) - private String name; + @Column(nullable = false, unique = true, length = 40) + private String name; - @Column(nullable = false) - private String description; + @Column(nullable = false) + private String description; - @Column(nullable = false) - private boolean top; + @Column(nullable = false) + private boolean top; - @Column(nullable = false) - private boolean middle; + @Column(nullable = false) + private boolean middle; - @Column(nullable = false) - private boolean base; + @Column(nullable = false) + private boolean base; - @OneToMany(mappedBy = "note", cascade = CascadeType.ALL) - @Builder.Default - private List fragranceTopNoteList = new ArrayList<>(); + @OneToMany(mappedBy = "note", cascade = CascadeType.ALL) + @Builder.Default + private List fragranceTopNoteList = new ArrayList<>(); - @OneToMany(mappedBy = "note", cascade = CascadeType.ALL) - @Builder.Default - private List fragranceMiddleNoteList = new ArrayList<>(); + @OneToMany(mappedBy = "note", cascade = CascadeType.ALL) + @Builder.Default + private List fragranceMiddleNoteList = new ArrayList<>(); - @OneToMany(mappedBy = "note", cascade = CascadeType.ALL) - @Builder.Default - private List fragranceBaseNoteList = new ArrayList<>(); + @OneToMany(mappedBy = "note", cascade = CascadeType.ALL) + @Builder.Default + private List fragranceBaseNoteList = new ArrayList<>(); - @OneToMany(mappedBy = "note", cascade = CascadeType.ALL) - @Builder.Default - private List userNoteList = new ArrayList<>(); + @OneToMany(mappedBy = "note", cascade = CascadeType.ALL) + @Builder.Default + private List userNoteList = new ArrayList<>(); } diff --git a/src/main/java/PerfumeOnMe/spring/domain/PBTI.java b/src/main/java/PerfumeOnMe/spring/domain/PBTI.java index d83675e..d8426f8 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/PBTI.java +++ b/src/main/java/PerfumeOnMe/spring/domain/PBTI.java @@ -1,14 +1,28 @@ package PerfumeOnMe.spring.domain; -import PerfumeOnMe.spring.domain.base.BaseEntity; -import PerfumeOnMe.spring.domain.mapping.RecommendedFragrance; -import jakarta.persistence.*; -import lombok.*; +import java.util.ArrayList; +import java.util.List; + import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import java.util.ArrayList; -import java.util.List; +import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.domain.mapping.RecommendedFragrance; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter @@ -17,16 +31,17 @@ @AllArgsConstructor @DynamicInsert @DynamicUpdate +@Table(name = "pbtis") public class PBTI extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - private User user; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; - @OneToMany(mappedBy = "pbti", cascade = CascadeType.ALL) - @Builder.Default - private List RecommendedFragranceList = new ArrayList<>(); + @OneToMany(mappedBy = "pbti", cascade = CascadeType.ALL) + @Builder.Default + private List RecommendedFragranceList = new ArrayList<>(); } diff --git a/src/main/java/PerfumeOnMe/spring/domain/Season.java b/src/main/java/PerfumeOnMe/spring/domain/Season.java index 9a950e9..8427be4 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Season.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Season.java @@ -1,14 +1,26 @@ package PerfumeOnMe.spring.domain; -import PerfumeOnMe.spring.domain.base.BaseEntity; -import PerfumeOnMe.spring.domain.mapping.FragranceSeason; -import jakarta.persistence.*; -import lombok.*; +import java.util.ArrayList; +import java.util.List; + import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import java.util.ArrayList; -import java.util.List; +import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.domain.mapping.FragranceSeason; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter @@ -17,15 +29,16 @@ @AllArgsConstructor @DynamicInsert @DynamicUpdate +@Table(name = "seasons") public class Season extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(nullable = false, unique = true, length = 15) - private String name; + @Column(nullable = false, unique = true, length = 15) + private String name; - @OneToMany(mappedBy = "season", cascade = CascadeType.ALL) - @Builder.Default - private List fragranceSeasonList = new ArrayList<>(); + @OneToMany(mappedBy = "season", cascade = CascadeType.ALL) + @Builder.Default + private List fragranceSeasonList = new ArrayList<>(); } diff --git a/src/main/java/PerfumeOnMe/spring/domain/Terms.java b/src/main/java/PerfumeOnMe/spring/domain/Terms.java index 3b331b8..ed528c0 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Terms.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Terms.java @@ -1,13 +1,25 @@ package PerfumeOnMe.spring.domain; -import PerfumeOnMe.spring.domain.mapping.UserTerms; -import jakarta.persistence.*; -import lombok.*; +import java.util.ArrayList; +import java.util.List; + import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import java.util.ArrayList; -import java.util.List; +import PerfumeOnMe.spring.domain.mapping.UserTerms; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter @@ -16,22 +28,23 @@ @AllArgsConstructor @DynamicInsert @DynamicUpdate +@Table(name = "terms") public class Terms { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(nullable = false, unique = true, length = 100) - private String title; + @Column(nullable = false, unique = true, length = 100) + private String title; - @Column(columnDefinition = "TEXT",nullable = false) - private String content; + @Column(columnDefinition = "TEXT", nullable = false) + private String content; - @Column(nullable = false) - private boolean required; + @Column(nullable = false) + private boolean required; - @OneToMany(mappedBy = "terms", cascade = CascadeType.ALL) - @Builder.Default - private List userTermsList = new ArrayList<>(); + @OneToMany(mappedBy = "terms", cascade = CascadeType.ALL) + @Builder.Default + private List userTermsList = new ArrayList<>(); } diff --git a/src/main/java/PerfumeOnMe/spring/domain/User.java b/src/main/java/PerfumeOnMe/spring/domain/User.java index d34e39d..39f14d5 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/User.java +++ b/src/main/java/PerfumeOnMe/spring/domain/User.java @@ -1,5 +1,12 @@ package PerfumeOnMe.spring.domain; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + import PerfumeOnMe.spring.domain.base.BaseEntity; import PerfumeOnMe.spring.domain.enums.Age; import PerfumeOnMe.spring.domain.enums.Social; @@ -9,14 +16,21 @@ import PerfumeOnMe.spring.domain.mapping.UserFragrance; import PerfumeOnMe.spring.domain.mapping.UserNote; import PerfumeOnMe.spring.domain.mapping.UserTerms; -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.DynamicInsert; -import org.hibernate.annotations.DynamicUpdate; - -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter @@ -25,79 +39,80 @@ @AllArgsConstructor @DynamicInsert @DynamicUpdate +@Table(name = "users") public class User extends BaseEntity { - // ----- 필드 ----- - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + // ----- 필드 ----- + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(nullable = false, unique = true, length = 25) - private String name; + @Column(nullable = false, unique = true, length = 25) + private String name; - @Column(unique = true, length = 25) - private String nickname; + @Column(unique = true, length = 25) + private String nickname; - @Enumerated(EnumType.STRING) - @Column(columnDefinition = "VARCHAR(10) DEFAULT 'NONE'") - private Age age; + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "VARCHAR(10) DEFAULT 'NONE'") + private Age age; - @Enumerated(EnumType.STRING) - @Column(columnDefinition = "VARCHAR(10) DEFAULT 'NONE'") - private UserGender gender; + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "VARCHAR(10) DEFAULT 'NONE'") + private UserGender gender; - @Column(nullable = false, unique = true, length = 30) - private String email; + @Column(nullable = false, unique = true, length = 30) + private String email; - @Column(nullable = false, unique = true, length = 30) - private String loginId; + @Column(nullable = false, unique = true, length = 30) + private String loginId; - @Column(columnDefinition = "TEXT", nullable = false) - private String password; + @Column(columnDefinition = "TEXT", nullable = false) + private String password; - @Enumerated(EnumType.STRING) - @Column(columnDefinition = "VARCHAR(10) DEFAULT 'LOCAL'") - private Social social; + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "VARCHAR(10) DEFAULT 'LOCAL'") + private Social social; - @Enumerated(EnumType.STRING) - @Column(columnDefinition = "VARCHAR(10) DEFAULT 'ACTIVE'", nullable = false) - private UserStatus status; + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "VARCHAR(10) DEFAULT 'ACTIVE'", nullable = false) + private UserStatus status; - private LocalDate inactiveDate; + private LocalDate inactiveDate; - @Column(columnDefinition = "TEXT") - private String imageURL; + @Column(columnDefinition = "TEXT") + private String imageURL; - // ----- 매핑 ----- - @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) - @Builder.Default - private List userFragranceList = new ArrayList<>(); + // ----- 매핑 ----- + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + @Builder.Default + private List userFragranceList = new ArrayList<>(); - @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) - @Builder.Default - private List userTermsList = new ArrayList<>(); + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + @Builder.Default + private List userTermsList = new ArrayList<>(); - @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) - @Builder.Default - private List diaryList = new ArrayList<>(); + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + @Builder.Default + private List diaryList = new ArrayList<>(); - @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) - @Builder.Default - private List chatMessageList = new ArrayList<>(); + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + @Builder.Default + private List chatMessageList = new ArrayList<>(); - @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) - @Builder.Default - private List userNoteList = new ArrayList<>(); + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + @Builder.Default + private List userNoteList = new ArrayList<>(); - @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) - @Builder.Default - private List pbtiList = new ArrayList<>(); + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + @Builder.Default + private List pbtiList = new ArrayList<>(); - @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) - @Builder.Default - private List imageKeywordList = new ArrayList<>(); + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + @Builder.Default + private List imageKeywordList = new ArrayList<>(); - @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) - @Builder.Default - private List workshopList = new ArrayList<>(); + @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) + @Builder.Default + private List workshopList = new ArrayList<>(); } diff --git a/src/main/java/PerfumeOnMe/spring/domain/Workshop.java b/src/main/java/PerfumeOnMe/spring/domain/Workshop.java index 0799d1d..a5a6361 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Workshop.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Workshop.java @@ -1,14 +1,28 @@ package PerfumeOnMe.spring.domain; -import PerfumeOnMe.spring.domain.base.BaseEntity; -import PerfumeOnMe.spring.domain.mapping.RecommendedFragrance; -import jakarta.persistence.*; -import lombok.*; +import java.util.ArrayList; +import java.util.List; + import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import java.util.ArrayList; -import java.util.List; +import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.domain.mapping.RecommendedFragrance; +import jakarta.persistence.CascadeType; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter @@ -17,16 +31,17 @@ @AllArgsConstructor @DynamicInsert @DynamicUpdate +@Table(name = "workshops") public class Workshop extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - private User user; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; - @OneToMany(mappedBy = "workshop", cascade = CascadeType.ALL) - @Builder.Default - private List RecommendedFragranceList = new ArrayList<>(); + @OneToMany(mappedBy = "workshop", cascade = CascadeType.ALL) + @Builder.Default + private List RecommendedFragranceList = new ArrayList<>(); } diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/Diary.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/Diary.java index 202281c..a4a584e 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/Diary.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/Diary.java @@ -1,14 +1,27 @@ package PerfumeOnMe.spring.domain.mapping; -import PerfumeOnMe.spring.domain.Fragrance; -import PerfumeOnMe.spring.domain.User; -import PerfumeOnMe.spring.domain.base.BaseEntity; -import jakarta.persistence.*; -import lombok.*; +import java.time.LocalDate; + import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import java.time.LocalDate; +import PerfumeOnMe.spring.domain.Fragrance; +import PerfumeOnMe.spring.domain.User; +import PerfumeOnMe.spring.domain.base.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter @@ -17,24 +30,24 @@ @AllArgsConstructor @DynamicInsert @DynamicUpdate - +@Table(name = "diaries") public class Diary extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - private User user; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "fragrance_id") - private Fragrance fragrance; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fragrance_id") + private Fragrance fragrance; - @Column(columnDefinition = "TEXT", nullable = false) - private String content; + @Column(columnDefinition = "TEXT", nullable = false) + private String content; - @Column(nullable = false) - private LocalDate date; + @Column(nullable = false) + private LocalDate date; } diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceBaseNote.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceBaseNote.java index a75b5e6..16b2e3a 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceBaseNote.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceBaseNote.java @@ -1,12 +1,24 @@ package PerfumeOnMe.spring.domain.mapping; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + import PerfumeOnMe.spring.domain.Fragrance; import PerfumeOnMe.spring.domain.Note; import PerfumeOnMe.spring.domain.base.BaseEntity; -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.DynamicInsert; -import org.hibernate.annotations.DynamicUpdate; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter @@ -15,16 +27,17 @@ @AllArgsConstructor @DynamicInsert @DynamicUpdate +@Table(name = "fragrance_base_notes") public class FragranceBaseNote extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "note_id") - private Note note; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "note_id") + private Note note; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "fragrance_id") - private Fragrance fragrance; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fragrance_id") + private Fragrance fragrance; } diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceLocation.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceLocation.java index 506f232..a46db2a 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceLocation.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceLocation.java @@ -1,12 +1,24 @@ package PerfumeOnMe.spring.domain.mapping; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + import PerfumeOnMe.spring.domain.Fragrance; import PerfumeOnMe.spring.domain.Location; import PerfumeOnMe.spring.domain.base.BaseEntity; -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.DynamicInsert; -import org.hibernate.annotations.DynamicUpdate; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter @@ -15,17 +27,18 @@ @AllArgsConstructor @DynamicInsert @DynamicUpdate +@Table(name = "fragrance_locations") public class FragranceLocation extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "location_id") - private Location location; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "location_id") + private Location location; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "fragrance_id") - private Fragrance fragrance; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fragrance_id") + private Fragrance fragrance; } diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceMiddleNote.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceMiddleNote.java index abe659a..f1b073f 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceMiddleNote.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceMiddleNote.java @@ -1,12 +1,24 @@ package PerfumeOnMe.spring.domain.mapping; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + import PerfumeOnMe.spring.domain.Fragrance; import PerfumeOnMe.spring.domain.Note; import PerfumeOnMe.spring.domain.base.BaseEntity; -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.DynamicInsert; -import org.hibernate.annotations.DynamicUpdate; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter @@ -15,16 +27,17 @@ @AllArgsConstructor @DynamicInsert @DynamicUpdate +@Table(name = "fragrance_middle_notes") public class FragranceMiddleNote extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "note_id") - private Note note; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "note_id") + private Note note; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "fragrance_id") - private Fragrance fragrance; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fragrance_id") + private Fragrance fragrance; } diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceSeason.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceSeason.java index 77278e6..bea6524 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceSeason.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceSeason.java @@ -1,12 +1,24 @@ package PerfumeOnMe.spring.domain.mapping; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + import PerfumeOnMe.spring.domain.Fragrance; import PerfumeOnMe.spring.domain.Season; import PerfumeOnMe.spring.domain.base.BaseEntity; -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.DynamicInsert; -import org.hibernate.annotations.DynamicUpdate; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter @@ -15,17 +27,18 @@ @AllArgsConstructor @DynamicInsert @DynamicUpdate +@Table(name = "fragrance_seasons") public class FragranceSeason extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "season_id") - private Season season; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "season_id") + private Season season; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "fragrance_id") - private Fragrance fragrance; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fragrance_id") + private Fragrance fragrance; } diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceTopNote.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceTopNote.java index 869d87d..a7e5e5a 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceTopNote.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceTopNote.java @@ -1,13 +1,24 @@ package PerfumeOnMe.spring.domain.mapping; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + import PerfumeOnMe.spring.domain.Fragrance; -import PerfumeOnMe.spring.domain.Location; import PerfumeOnMe.spring.domain.Note; import PerfumeOnMe.spring.domain.base.BaseEntity; -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.DynamicInsert; -import org.hibernate.annotations.DynamicUpdate; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter @@ -16,16 +27,17 @@ @AllArgsConstructor @DynamicInsert @DynamicUpdate -public class FragranceTopNote extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; +@Table(name = "fragrance_top_notes") +public class FragranceTopNote extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "note_id") - private Note note; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "note_id") + private Note note; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "fragrance_id") - private Fragrance fragrance; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fragrance_id") + private Fragrance fragrance; } diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/RecommendedFragrance.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/RecommendedFragrance.java index be1baed..cef59db 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/RecommendedFragrance.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/RecommendedFragrance.java @@ -1,15 +1,27 @@ package PerfumeOnMe.spring.domain.mapping; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + import PerfumeOnMe.spring.domain.Fragrance; import PerfumeOnMe.spring.domain.ImageKeyword; import PerfumeOnMe.spring.domain.PBTI; import PerfumeOnMe.spring.domain.Workshop; import PerfumeOnMe.spring.domain.base.BaseEntity; -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.DynamicInsert; -import org.hibernate.annotations.DynamicUpdate; -import org.hibernate.jdbc.Work; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter @@ -18,28 +30,29 @@ @AllArgsConstructor @DynamicInsert @DynamicUpdate +@Table(name = "recommended_fragrances") public class RecommendedFragrance extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @Column(nullable = false, length = 100) - private String name; + @Column(nullable = false, length = 100) + private String name; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "fragrance_id") - private Fragrance fragrance; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fragrance_id") + private Fragrance fragrance; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "imageKeyword_id") - private ImageKeyword imageKeyword; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "imageKeyword_id") + private ImageKeyword imageKeyword; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "pbti_id") - private PBTI pbti; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "pbti_id") + private PBTI pbti; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "workshop_id") - private Workshop workshop; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "workshop_id") + private Workshop workshop; } diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/UserFragrance.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/UserFragrance.java index c9f3a65..1d63668 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/UserFragrance.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/UserFragrance.java @@ -1,12 +1,24 @@ package PerfumeOnMe.spring.domain.mapping; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + import PerfumeOnMe.spring.domain.Fragrance; import PerfumeOnMe.spring.domain.User; import PerfumeOnMe.spring.domain.base.BaseEntity; -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.DynamicInsert; -import org.hibernate.annotations.DynamicUpdate; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter @@ -15,16 +27,17 @@ @AllArgsConstructor @DynamicInsert @DynamicUpdate +@Table(name = "user_fragrances") public class UserFragrance extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - private User user; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "fragrance_id") - private Fragrance fragrance; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fragrance_id") + private Fragrance fragrance; } diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/UserNote.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/UserNote.java index bf63dbf..c9392f1 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/UserNote.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/UserNote.java @@ -1,12 +1,24 @@ package PerfumeOnMe.spring.domain.mapping; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + import PerfumeOnMe.spring.domain.Note; import PerfumeOnMe.spring.domain.User; import PerfumeOnMe.spring.domain.base.BaseEntity; -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.DynamicInsert; -import org.hibernate.annotations.DynamicUpdate; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter @@ -15,17 +27,18 @@ @AllArgsConstructor @DynamicInsert @DynamicUpdate +@Table(name = "user_notes") public class UserNote extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - private User user; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "note_id") - private Note note; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "note_id") + private Note note; } diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/UserTerms.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/UserTerms.java index 8b1aeda..6b13c94 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/UserTerms.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/UserTerms.java @@ -1,12 +1,25 @@ package PerfumeOnMe.spring.domain.mapping; +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + import PerfumeOnMe.spring.domain.Terms; import PerfumeOnMe.spring.domain.User; import PerfumeOnMe.spring.domain.base.BaseEntity; -import jakarta.persistence.*; -import lombok.*; -import org.hibernate.annotations.DynamicInsert; -import org.hibernate.annotations.DynamicUpdate; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; @Entity @Getter @@ -15,19 +28,20 @@ @AllArgsConstructor @DynamicInsert @DynamicUpdate +@Table(name = "user_terms") public class UserTerms extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - private User user; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "terms_id") - private Terms terms; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "terms_id") + private Terms terms; - @Column(nullable = false) - private boolean agreement; + @Column(nullable = false) + private boolean agreement; } diff --git a/src/main/java/PerfumeOnMe/spring/repository/UserRepository.java b/src/main/java/PerfumeOnMe/spring/repository/UserRepository.java deleted file mode 100644 index 7ff683b..0000000 --- a/src/main/java/PerfumeOnMe/spring/repository/UserRepository.java +++ /dev/null @@ -1,4 +0,0 @@ -package PerfumeOnMe.spring.repository; - -public interface UserRepository { -} diff --git a/src/main/java/PerfumeOnMe/spring/repository/user/UserRepository.java b/src/main/java/PerfumeOnMe/spring/repository/user/UserRepository.java new file mode 100644 index 0000000..790d4d4 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/user/UserRepository.java @@ -0,0 +1,8 @@ +package PerfumeOnMe.spring.repository.user; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.domain.User; + +public interface UserRepository extends JpaRepository, UserRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/user/UserRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/user/UserRepositoryCustom.java new file mode 100644 index 0000000..b87443c --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/user/UserRepositoryCustom.java @@ -0,0 +1,5 @@ +package PerfumeOnMe.spring.repository.user; + +public interface UserRepositoryCustom { + +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/user/UserRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/repository/user/UserRepositoryImpl.java new file mode 100644 index 0000000..5df4026 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/user/UserRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.repository.user; + +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class UserRepositoryImpl implements UserRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/service/UserService.java b/src/main/java/PerfumeOnMe/spring/service/UserService.java deleted file mode 100644 index 798ea46..0000000 --- a/src/main/java/PerfumeOnMe/spring/service/UserService.java +++ /dev/null @@ -1,4 +0,0 @@ -package PerfumeOnMe.spring.service; - -public class UserService { -} diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserService.java b/src/main/java/PerfumeOnMe/spring/service/user/UserService.java new file mode 100644 index 0000000..58a5674 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserService.java @@ -0,0 +1,5 @@ +package PerfumeOnMe.spring.service.user; + +public interface UserService { + +} diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java new file mode 100644 index 0000000..ceb8a55 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java @@ -0,0 +1,13 @@ +package PerfumeOnMe.spring.service.user; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class UserServiceImpl { + +} diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUser.java b/src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUser.java index b7bbfc6..dffa901 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUser.java +++ b/src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUser.java @@ -1,4 +1,17 @@ package PerfumeOnMe.spring.validation.annotation; +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import PerfumeOnMe.spring.validation.validator.ExistUserValidator; +import jakarta.validation.Constraint; + +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = ExistUserValidator.class) +@Documented public @interface ExistUser { } diff --git a/src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserValidator.java b/src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserValidator.java index 3da924c..f4f7d29 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserValidator.java +++ b/src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserValidator.java @@ -1,4 +1,19 @@ package PerfumeOnMe.spring.validation.validator; -public class ExistUserValidator { +import org.springframework.stereotype.Component; + +import PerfumeOnMe.spring.validation.annotation.ExistUser; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ExistUserValidator + implements ConstraintValidator { + + @Override + public boolean isValid(Long value, ConstraintValidatorContext context) { + return false; + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java index da0cf37..20c6a59 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java @@ -1,4 +1,16 @@ package PerfumeOnMe.spring.web.controller; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/users") +@Tag(name = "User", description = "사용자 CRUD API") public class UserController { } From 0a5a1fa973311398008a0b4fa2de160bb1835236 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Thu, 3 Jul 2025 19:59:41 +0900 Subject: [PATCH 017/339] =?UTF-8?q?[Feature]=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=9A=94=EC=B2=AD=20DTO=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EB=B0=8F=20=ED=95=84=EB=93=9C=20=EC=A0=9C=EC=95=BD=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EC=A0=81=EC=9A=A9=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/web/dto/user/UserRequestDTO.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java index 73ae338..192df02 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java @@ -1,4 +1,43 @@ package PerfumeOnMe.spring.web.dto.user; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.NoArgsConstructor; + public class UserRequestDTO { + + @Getter + @NoArgsConstructor + public static class Signup { + @NotBlank + @Schema(description = "사용자가 입력한 이름", example = "홍길동") + @Pattern( + regexp = "^[가-힣]+$", + message = "한글만 입력할 수 있으며, 공백 없이 1자 이상 입력해주세요." + ) + private String name; + @NotBlank + @Schema(description = "사용자가 입력한 아이디", example = "umc123") + @Pattern( + regexp = "^[a-z0-9]+$", + message = "영어 소문자와 숫자만 입력할 수 있으며, 공백 없이 1자 이상 입력해주세요." + ) + private String loginId; + @NotBlank + @Schema(description = "사용자가 입력한 비밀번호", example = "asdf1234") + @Pattern( + regexp = "^[A-Za-z\\d@$!%*?&#]{8,20}$", + message = "비밀번호는 영어 대소문자, 숫자, 특수문자(@$!%*?&#)만 허용되며, 공백 없이 8자 이상 20자 이하로 입력해주세요." + ) + private String password; + @NotBlank + @Schema(description = "사용자가 입력한 비밀번호 확인", example = "asdf1234") + @Pattern( + regexp = "^[A-Za-z\\d@$!%*?&#]{8,20}$", + message = "비밀번호는 영어 대소문자, 숫자, 특수문자(@$!%*?&#)만 허용되며, 공백 없이 8자 이상 20자 이하로 입력해주세요." + ) + private String passwordConfirm; + } } From f6ecef7028567dee89e1939ccd393f5526aca5f2 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Thu, 3 Jul 2025 19:59:57 +0900 Subject: [PATCH 018/339] =?UTF-8?q?[Feature]=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=9D=91=EB=8B=B5=20DTO=20=EC=83=9D=EC=84=B1=20(#9?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/web/dto/user/UserResponseDTO.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/user/UserResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/user/UserResponseDTO.java index 65a8aff..b2f196a 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/user/UserResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/user/UserResponseDTO.java @@ -1,4 +1,17 @@ package PerfumeOnMe.spring.web.dto.user; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + public class UserResponseDTO { + + @Builder + @Getter + @AllArgsConstructor + @NoArgsConstructor + public static class SignupResult { + Long userId; + } } From 18ee43a621df12531abd0fdf0ed44423f8a4e986 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Thu, 3 Jul 2025 20:01:40 +0900 Subject: [PATCH 019/339] =?UTF-8?q?[Fix]=20User=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=ED=95=84=EB=93=9C=20=EC=82=AD=EC=A0=9C=20=EB=B0=8F?= =?UTF-8?q?=20=EC=A1=B0=EA=B1=B4=20=EC=88=98=EC=A0=95=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/PerfumeOnMe/spring/domain/User.java | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/domain/User.java b/src/main/java/PerfumeOnMe/spring/domain/User.java index 39f14d5..465726b 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/User.java +++ b/src/main/java/PerfumeOnMe/spring/domain/User.java @@ -1,6 +1,5 @@ package PerfumeOnMe.spring.domain; -import java.time.LocalDate; import java.util.ArrayList; import java.util.List; @@ -11,7 +10,6 @@ import PerfumeOnMe.spring.domain.enums.Age; import PerfumeOnMe.spring.domain.enums.Social; import PerfumeOnMe.spring.domain.enums.UserGender; -import PerfumeOnMe.spring.domain.enums.UserStatus; import PerfumeOnMe.spring.domain.mapping.Diary; import PerfumeOnMe.spring.domain.mapping.UserFragrance; import PerfumeOnMe.spring.domain.mapping.UserNote; @@ -47,10 +45,10 @@ public class User extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(nullable = false, unique = true, length = 25) + @Column(nullable = false) private String name; - @Column(unique = true, length = 25) + @Column(unique = true, length = 10) private String nickname; @Enumerated(EnumType.STRING) @@ -61,10 +59,7 @@ public class User extends BaseEntity { @Column(columnDefinition = "VARCHAR(10) DEFAULT 'NONE'") private UserGender gender; - @Column(nullable = false, unique = true, length = 30) - private String email; - - @Column(nullable = false, unique = true, length = 30) + @Column(nullable = false, unique = true) private String loginId; @Column(columnDefinition = "TEXT", nullable = false) @@ -74,12 +69,6 @@ public class User extends BaseEntity { @Column(columnDefinition = "VARCHAR(10) DEFAULT 'LOCAL'") private Social social; - @Enumerated(EnumType.STRING) - @Column(columnDefinition = "VARCHAR(10) DEFAULT 'ACTIVE'", nullable = false) - private UserStatus status; - - private LocalDate inactiveDate; - @Column(columnDefinition = "TEXT") private String imageURL; From 766e187125f66b6d0cc61334ad7d619b0f1065f6 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Thu, 3 Jul 2025 20:07:37 +0900 Subject: [PATCH 020/339] =?UTF-8?q?[Feature]=20=EB=AC=B8=EC=9E=90=EC=97=B4?= =?UTF-8?q?=20=EC=95=94=ED=98=B8=ED=99=94=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?PasswordEncoder=20=EB=B9=88=20=EB=93=B1=EB=A1=9D=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/security/SecurityConfig.java | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java b/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java index d44e133..8a35551 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java @@ -4,23 +4,31 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; @Configuration @EnableWebSecurity public class SecurityConfig { - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http - .authorizeHttpRequests(auth -> auth - .anyRequest().permitAll() - ) - .formLogin(config -> config.disable()) - .httpBasic(config -> config.disable()) - .csrf(config -> config.disable()); + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(auth -> auth + .anyRequest().permitAll() + ) + .formLogin(config -> config.disable()) + .httpBasic(config -> config.disable()) + .csrf(config -> config.disable()); - return http.build(); - } + return http.build(); + } + + // 문자열 암호화를 위한 PasswordEncoder 빈 등록 + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } } From 7cc04f88476b5d623f33038ee9e96c8a533d034f Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Thu, 3 Jul 2025 20:10:29 +0900 Subject: [PATCH 021/339] =?UTF-8?q?[Feature]=20=EC=9D=BC=EB=B0=98=EC=A0=81?= =?UTF-8?q?=EC=9D=B8=20=EC=97=90=EB=9F=AC=EC=99=80=20=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=EC=9E=90=20=EC=97=90=EB=9F=AC=20=EC=B6=94=EA=B0=80=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apiPayload/code/status/ErrorStatus.java | 65 +++++++++++-------- 1 file changed, 38 insertions(+), 27 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index 1e9fa95..448c9f3 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -1,40 +1,51 @@ package PerfumeOnMe.spring.apiPayload.code.status; +import org.springframework.http.HttpStatus; + import PerfumeOnMe.spring.apiPayload.code.BaseErrorCode; import PerfumeOnMe.spring.apiPayload.code.ErrorReasonDTO; import lombok.AllArgsConstructor; import lombok.Getter; -import org.springframework.http.HttpStatus; @Getter @AllArgsConstructor public enum ErrorStatus implements BaseErrorCode { - // 예시,,, - ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); - - private final HttpStatus httpStatus; - private final String code; - private final String message; - - @Override - public ErrorReasonDTO getReason() { - return ErrorReasonDTO.builder() - .message(message) - .code(code) - .isSuccess(false) - .build(); - } - - @Override - public ErrorReasonDTO getReasonHttpStatus() { - return ErrorReasonDTO.builder() - .message(message) - .code(code) - .isSuccess(false) - .httpStatus(httpStatus) - .build() - ; - } + //일반적인 에러 + _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 에러, 관리자에게 문의 바랍니다."), + _BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "잘못된 요청입니다."), + _UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "인증이 필요합니다."), + _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."), + + // 사용자 에러 + LOGIN_ID_DUPLICATE(HttpStatus.BAD_REQUEST, "MEMBER4001", "이미 사용된 아이디입니다."), + PASSWORD_CONFIRM_FAIL(HttpStatus.BAD_REQUEST, "MEMBER4002", "비밀번호 확인을 실패했습니다."), + + // 예시,,, + ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDTO getReason() { + return ErrorReasonDTO.builder() + .message(message) + .code(code) + .isSuccess(false) + .build(); + } + + @Override + public ErrorReasonDTO getReasonHttpStatus() { + return ErrorReasonDTO.builder() + .message(message) + .code(code) + .isSuccess(false) + .httpStatus(httpStatus) + .build() + ; + } } From ef4b6902ae470e55a1c7da0ef82721708802a683 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Thu, 3 Jul 2025 20:11:06 +0900 Subject: [PATCH 022/339] =?UTF-8?q?[Feature]=20201=20CREATED=20HttpStatus?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apiPayload/code/status/SuccessStatus.java | 56 ++++++++++--------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/SuccessStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/SuccessStatus.java index 26c3790..a0e5d46 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/SuccessStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/SuccessStatus.java @@ -1,40 +1,42 @@ package PerfumeOnMe.spring.apiPayload.code.status; +import org.springframework.http.HttpStatus; + import PerfumeOnMe.spring.apiPayload.code.BaseCode; import PerfumeOnMe.spring.apiPayload.code.ReasonDTO; import lombok.AllArgsConstructor; import lombok.Getter; -import org.springframework.http.HttpStatus; @Getter @AllArgsConstructor public enum SuccessStatus implements BaseCode { - // 일반적인 응답 - _OK(HttpStatus.OK, "COMMON200", "성공입니다."); - - private final HttpStatus httpStatus; - private final String code; - private final String message; - - @Override - public ReasonDTO getReason() { - return ReasonDTO.builder() - .message(message) - .code(code) - .isSuccess(true) - .build(); - } - - @Override - public ReasonDTO getReasonHttpStatus() { - return ReasonDTO.builder() - .message(message) - .code(code) - .isSuccess(true) - .httpStatus(httpStatus) - .build() - ; - } + // 일반적인 응답 + _OK(HttpStatus.OK, "COMMON200", "성공입니다."), + _CREATED(HttpStatus.OK, "COMMON201", "리소스를 성공적으로 생성했습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ReasonDTO getReason() { + return ReasonDTO.builder() + .message(message) + .code(code) + .isSuccess(true) + .build(); + } + + @Override + public ReasonDTO getReasonHttpStatus() { + return ReasonDTO.builder() + .message(message) + .code(code) + .isSuccess(true) + .httpStatus(httpStatus) + .build() + ; + } } From ed16914198aac2edb668d0a2c79773beb5705c93 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Thu, 3 Jul 2025 20:11:40 +0900 Subject: [PATCH 023/339] =?UTF-8?q?[Fix]=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20Enum=20=EC=82=AD=EC=A0=9C=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/PerfumeOnMe/spring/domain/enums/UserStatus.java | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 src/main/java/PerfumeOnMe/spring/domain/enums/UserStatus.java diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/UserStatus.java b/src/main/java/PerfumeOnMe/spring/domain/enums/UserStatus.java deleted file mode 100644 index ff8534a..0000000 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/UserStatus.java +++ /dev/null @@ -1,5 +0,0 @@ -package PerfumeOnMe.spring.domain.enums; - -public enum UserStatus { - ACTIVE, INACTIVE, DELETED -} From fcfa790cecdee1fb34dcfe2e8c3cfeadd53b34fa Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Thu, 3 Jul 2025 20:12:38 +0900 Subject: [PATCH 024/339] =?UTF-8?q?[Style]=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/apiPayload/ApiResponse.java | 41 ++++++++++--------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/ApiResponse.java b/src/main/java/PerfumeOnMe/spring/apiPayload/ApiResponse.java index fbd98b1..c20af40 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/ApiResponse.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/ApiResponse.java @@ -1,10 +1,11 @@ package PerfumeOnMe.spring.apiPayload; -import PerfumeOnMe.spring.apiPayload.code.BaseCode; -import PerfumeOnMe.spring.apiPayload.code.status.SuccessStatus; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +import PerfumeOnMe.spring.apiPayload.code.BaseCode; +import PerfumeOnMe.spring.apiPayload.code.status.SuccessStatus; import lombok.AllArgsConstructor; import lombok.Getter; @@ -13,24 +14,26 @@ @JsonPropertyOrder({"isSuccess", "code", "message", "result"}) public class ApiResponse { - @JsonProperty("isSuccess") - private final Boolean isSuccess; - private final String code; - private final String message; - @JsonInclude(JsonInclude.Include.NON_NULL) - private T result; - + @JsonProperty("isSuccess") + private final Boolean isSuccess; + private final String code; + private final String message; + @JsonInclude(JsonInclude.Include.NON_NULL) + private T result; - public static ApiResponse onSuccess(T result){ - return new ApiResponse<>(true, SuccessStatus._OK.getCode() , SuccessStatus._OK.getMessage(), result); - } + // 200 OK + public static ApiResponse onSuccess(T result) { + return new ApiResponse<>(true, SuccessStatus._OK.getCode(), SuccessStatus._OK.getMessage(), result); + } - public static ApiResponse of(BaseCode code, T result){ - return new ApiResponse<>(true, code.getReasonHttpStatus().getCode() , code.getReasonHttpStatus().getMessage(), result); - } + // 201 CREATED, ... + public static ApiResponse of(BaseCode code, T result) { + return new ApiResponse<>(true, code.getReasonHttpStatus().getCode(), code.getReasonHttpStatus().getMessage(), + result); + } - // 실패한 경우 응답 생성 - public static ApiResponse onFailure(String code, String message, T data){ - return new ApiResponse<>(false, code, message, data); - } + // 400 CLIENT, 500 SERVER 등 실패한 경우 응답 생성 + public static ApiResponse onFailure(String code, String message, T data) { + return new ApiResponse<>(false, code, message, data); + } } From a4396839c139cda3a76a4f5599ceb35f5e97114d Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Thu, 3 Jul 2025 20:13:54 +0900 Subject: [PATCH 025/339] =?UTF-8?q?[Feature]=20DTO=EC=99=80=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=EB=A5=BC=20=EB=B3=80=ED=99=98=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/converter/UserConverter.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/converter/UserConverter.java b/src/main/java/PerfumeOnMe/spring/converter/UserConverter.java index 034065e..4d6f64a 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/UserConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/UserConverter.java @@ -1,4 +1,23 @@ package PerfumeOnMe.spring.converter; +import PerfumeOnMe.spring.domain.User; +import PerfumeOnMe.spring.web.dto.user.UserResponseDTO; + public class UserConverter { + + // 사용자 회원가입을 위한 엔티티로 변환 + public static User toSignupUser(String name, String loginId, String password) { + return User.builder() + .name(name) + .loginId(loginId) + .password(password) + .build(); + } + + // 사용자를 회원가입 결과 DTO 반환 + public static UserResponseDTO.SignupResult toSignupResult(User user) { + return UserResponseDTO.SignupResult.builder() + .userId(user.getId()) + .build(); + } } From dadb2b4689587b2067bb6dd1948d36af3e43baff Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Thu, 3 Jul 2025 20:16:47 +0900 Subject: [PATCH 026/339] =?UTF-8?q?[Feature]=20=EC=9E=90=EC=B2=B4=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20API=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/user/UserRepository.java | 4 ++ .../spring/service/user/UserService.java | 4 ++ .../spring/service/user/UserServiceImpl.java | 46 ++++++++++++++++++- .../spring/web/controller/UserController.java | 35 ++++++++++++++ 4 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/main/java/PerfumeOnMe/spring/repository/user/UserRepository.java b/src/main/java/PerfumeOnMe/spring/repository/user/UserRepository.java index 790d4d4..ef38f85 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/user/UserRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/user/UserRepository.java @@ -1,8 +1,12 @@ package PerfumeOnMe.spring.repository.user; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import PerfumeOnMe.spring.domain.User; public interface UserRepository extends JpaRepository, UserRepositoryCustom { + + Optional findUserByLoginId(String loginId); } diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserService.java b/src/main/java/PerfumeOnMe/spring/service/user/UserService.java index 58a5674..fe6d507 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserService.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserService.java @@ -1,5 +1,9 @@ package PerfumeOnMe.spring.service.user; +import PerfumeOnMe.spring.web.dto.user.UserRequestDTO; +import PerfumeOnMe.spring.web.dto.user.UserResponseDTO; + public interface UserService { + UserResponseDTO.SignupResult signup(UserRequestDTO.Signup request); } diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java index ceb8a55..7c41176 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java @@ -1,13 +1,57 @@ package PerfumeOnMe.spring.service.user; +import java.util.Optional; + +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.converter.UserConverter; +import PerfumeOnMe.spring.domain.User; +import PerfumeOnMe.spring.repository.user.UserRepository; +import PerfumeOnMe.spring.web.dto.user.UserRequestDTO; +import PerfumeOnMe.spring.web.dto.user.UserResponseDTO; import lombok.RequiredArgsConstructor; @Service @RequiredArgsConstructor @Transactional -public class UserServiceImpl { +public class UserServiceImpl implements UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + // 사용자 회원가입 + @Override + public UserResponseDTO.SignupResult signup(UserRequestDTO.Signup request) { + + // RequestDTO 값 추출하기 + String name = request.getName(); + String loginId = request.getLoginId(); + String password = request.getPassword(); + String passwordConfirm = request.getPasswordConfirm(); + + /* + 비즈니스 로직 검증 + 1. loginId 중복 확인 + 2. password와 passwordConfirm 일치 여부 확인 + */ + Optional findUser = userRepository.findUserByLoginId(loginId); + if (findUser.isPresent()) { + throw new GeneralException(ErrorStatus.LOGIN_ID_DUPLICATE); + } + if (!password.equals(passwordConfirm)) { + throw new GeneralException(ErrorStatus.PASSWORD_CONFIRM_FAIL); + } + + // 사용자 정보 엔티티 변환 및 DB 저장 + User newUser = UserConverter + .toSignupUser(name, loginId, passwordEncoder.encode(password)); + userRepository.save(newUser); + // 사용자 회원가입 결과를 ResponseDTO로 응답 + return UserConverter.toSignupResult(newUser); + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java index 20c6a59..0fd1c0b 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java @@ -1,9 +1,22 @@ package PerfumeOnMe.spring.web.controller; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.apiPayload.code.status.SuccessStatus; +import PerfumeOnMe.spring.service.user.UserService; +import PerfumeOnMe.spring.web.dto.user.UserRequestDTO; +import PerfumeOnMe.spring.web.dto.user.UserResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -13,4 +26,26 @@ @RequestMapping("/users") @Tag(name = "User", description = "사용자 CRUD API") public class UserController { + + private final UserService userService; + + @PostMapping("/signup") + @Operation( + summary = "자체 회원가입 API", + description = "사용자의 이름, 아이디, 비밀번호, 비밀번호 확인 값을 입력받아 회원가입하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON201", description = "리소스를 성공적으로 생성했습니다.", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserResponseDTO.SignupResult.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4001", description = "이미 사용된 아이디입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4002", description = "비밀번호 확인을 실패했습니다."), + }, + parameters = { + , + } + ) + public ResponseEntity> signup( + @RequestBody @Valid UserRequestDTO.Signup request) { + UserResponseDTO.SignupResult result = userService.signup(request); + return new ResponseEntity<>(ApiResponse.of(SuccessStatus._CREATED, result), HttpStatus.CREATED); + } } From 2ab3a8d8d6a88b9b880b2017bd0cf4f91098ec8d Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Thu, 3 Jul 2025 20:19:23 +0900 Subject: [PATCH 027/339] =?UTF-8?q?[Fix]=20=EC=9E=90=EC=B2=B4=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=20Controller=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/PerfumeOnMe/spring/web/controller/UserController.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java index 0fd1c0b..29f8cb7 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java @@ -38,9 +38,6 @@ public class UserController { content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserResponseDTO.SignupResult.class))), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4001", description = "이미 사용된 아이디입니다."), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4002", description = "비밀번호 확인을 실패했습니다."), - }, - parameters = { - , } ) public ResponseEntity> signup( From bec1438511dbfca5e89574cc8bf36579266755f0 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Thu, 3 Jul 2025 20:23:22 +0900 Subject: [PATCH 028/339] =?UTF-8?q?[Fix]=20lambda=EB=A5=BC=20method=20refe?= =?UTF-8?q?rence=EB=A1=9C=20=EB=8C=80=EC=B2=B4=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/config/security/SecurityConfig.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java b/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java index 8a35551..edb6f7c 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java @@ -4,6 +4,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; @@ -18,9 +19,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .authorizeHttpRequests(auth -> auth .anyRequest().permitAll() ) - .formLogin(config -> config.disable()) - .httpBasic(config -> config.disable()) - .csrf(config -> config.disable()); + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .csrf(AbstractHttpConfigurer::disable); return http.build(); } From 25be3b173af7d7c619f931ddde5d2edaf066a9ed Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Thu, 3 Jul 2025 20:25:19 +0900 Subject: [PATCH 029/339] =?UTF-8?q?[Fix]=20DTO=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=EC=97=90=20private=20=EC=A0=91=EA=B7=BC=20=EC=A0=9C=EC=96=B4?= =?UTF-8?q?=EC=9E=90=20=EC=84=A4=EC=A0=95=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/PerfumeOnMe/spring/web/dto/user/UserResponseDTO.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/user/UserResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/user/UserResponseDTO.java index b2f196a..b6c968d 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/user/UserResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/user/UserResponseDTO.java @@ -12,6 +12,6 @@ public class UserResponseDTO { @AllArgsConstructor @NoArgsConstructor public static class SignupResult { - Long userId; + private Long userId; } } From 4037cae25a3a5be434b4f915b8ad4eaec060dcdd Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Thu, 3 Jul 2025 20:30:11 +0900 Subject: [PATCH 030/339] =?UTF-8?q?[Fix]=20QueryDSL=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EA=B2=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 61 +++++++++++----------------------------------------- 1 file changed, 12 insertions(+), 49 deletions(-) diff --git a/build.gradle b/build.gradle index 843f36f..7368948 100644 --- a/build.gradle +++ b/build.gradle @@ -17,17 +17,12 @@ configurations { compileOnly { extendsFrom annotationProcessor } - querydsl {} } repositories { mavenCentral() } -ext { - querydslVersion = '5.1.0' -} - dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' @@ -41,8 +36,8 @@ dependencies { runtimeOnly 'com.mysql:mysql-connector-j' // ✅ QueryDSL (5.1.0) - implementation "com.querydsl:querydsl-jpa:$querydslVersion:jakarta" - annotationProcessor "com.querydsl:querydsl-apt:$querydslVersion:jakarta" + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" @@ -52,7 +47,7 @@ dependencies { // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' - // validation + // Validation implementation 'org.springframework.boot:spring-boot-starter-validation' // Security implementation 'org.springframework.boot:spring-boot-starter-security' @@ -64,51 +59,19 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.apache.commons:commons-pool2' // 커넥션 풀링 + // OAuth + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + // Query Parameter Log + implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' } tasks.named('test') { useJUnitPlatform() } -// QueryDSL 설정부 -def querydslDir = file("src/main/generated") - -sourceSets { - main { - java { - srcDirs += querydslDir - } - } -} - -// Q 클래스 중복 생성 방지 및 자동 생성 -tasks.named('compileJava') { - doFirst { - if (querydslDir.exists()) { - querydslDir.deleteDir() // Q 클래스 중복 생성 방지 - } - querydslDir.mkdirs() - } -} - -tasks.register("compileQuerydsl", JavaCompile) { - group = "build" - description = "Generates the QueryDSL Q-types" - source = sourceSets.main.java - destinationDirectory = querydslDir - classpath = sourceSets.main.compileClasspath - options.annotationProcessorPath = configurations.annotationProcessor - options.compilerArgs = [ - '-proc:only', - '-processor', 'com.querydsl.apt.jpa.JPAAnnotationProcessor' - ] -} - -// compileJava는 항상 compileQuerydsl 이후에 실행 -compileJava { - dependsOn compileQuerydsl -} - -jar { - enabled = false +clean { + delete file('src/main/generated') } \ No newline at end of file From 5352a9bdae7b5a1ba9460a6676155f3e27aefb15 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Thu, 3 Jul 2025 20:30:43 +0900 Subject: [PATCH 031/339] =?UTF-8?q?[Chore]=20.gitignore=EC=97=90=20applica?= =?UTF-8?q?tion-local.yml=20=EC=B6=94=EA=B0=80=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index c2065bc..5a6d032 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ out/ ### VS Code ### .vscode/ + +### application-local.yml ### +src/main/resources/application-local.yml \ No newline at end of file From 7aaa4fe343fdbdc3c2e5537b09e12e664ca1ba7a Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Thu, 3 Jul 2025 20:34:38 +0900 Subject: [PATCH 032/339] =?UTF-8?q?[Feature]=20application.yml=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=EC=9D=84=20local(default;=20.gitignore)?= =?UTF-8?q?=EA=B3=BC=20dev=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 4 ++++ src/main/resources/application.yml | 3 +++ 2 files changed, 7 insertions(+) create mode 100644 src/main/resources/application-dev.yml diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..7b8fdc5 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,4 @@ +spring: + config: + activate: + on-profile: dev \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e69de29..66f574e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -0,0 +1,3 @@ +spring: + profiles: + default: local \ No newline at end of file From 3fb74479de68af3e26e3fdd6d2be3cdbe55ed390 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Thu, 3 Jul 2025 21:14:47 +0900 Subject: [PATCH 033/339] =?UTF-8?q?[Fix]=20application.yml=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=EA=B0=92=20dev=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 20 +++++++++++++++++++- src/main/resources/application.yml | 2 +- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 7b8fdc5..bedcaec 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -1,4 +1,22 @@ spring: config: activate: - on-profile: dev \ No newline at end of file + on-profile: dev + datasource: + url: # RDS + username: # Username + password: # Password + driver-class-name: com.mysql.cj.jdbc.Driver + sql: + init: + mode: never + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + dialect: org.hibernate.dialect.MySQL8Dialect + format_sql: true + use_sql_comments: true + default_batch_fetch_size: 1000 + show-sql: true \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 66f574e..543df73 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,3 +1,3 @@ spring: profiles: - default: local \ No newline at end of file + default: dev \ No newline at end of file From 2e718893b0a70059ca991a7efb59b50162f5ecf7 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 4 Jul 2025 15:45:44 +0900 Subject: [PATCH 034/339] =?UTF-8?q?[Feature]=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EC=8B=9C=ED=8A=B8=20=EC=A0=80=EC=9E=A5=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?repository=20=EC=B6=94=EA=B0=80=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/fragrance/FragranceRepository.java | 11 +++++++++++ .../fragrance/FragranceRepositoryCustom.java | 4 ++++ .../repository/fragrance/FragranceRepositoryImpl.java | 10 ++++++++++ .../FragranceBaseNoteRepository.java | 9 +++++++++ .../FragranceBaseNoteRepositoryCustom.java | 4 ++++ .../FragranceBaseNoteRepositoryImpl.java | 10 ++++++++++ .../FragranceLocationRepository.java | 10 ++++++++++ .../FragranceLocationRepositoryCustom.java | 4 ++++ .../FragranceLocationRepositoryImpl.java | 10 ++++++++++ .../FragranceMiddleNoteRepository.java | 9 +++++++++ .../FragranceMiddleNoteRepositoryCustom.java | 4 ++++ .../FragranceMiddleNoteRepositoryImpl.java | 10 ++++++++++ .../fragrancePrice/FragrancePriceRepository.java | 8 ++++++++ .../FragrancePriceRepositoryCustom.java | 4 ++++ .../fragrancePrice/FragrancePriceRepositoryImpl.java | 10 ++++++++++ .../fragranceSeason/FragranceSeasonRepository.java | 9 +++++++++ .../FragranceSeasonRepositoryCustom.java | 4 ++++ .../FragranceSeasonRepositoryImpl.java | 10 ++++++++++ .../fragranceTopNote/FragranceTopNoteRepository.java | 9 +++++++++ .../FragranceTopNoteRepositoryCustom.java | 4 ++++ .../FragranceTopNoteRepositoryImpl.java | 10 ++++++++++ .../repository/location/LocationRepository.java | 11 +++++++++++ .../repository/location/LocationRepositoryCustom.java | 4 ++++ .../repository/location/LocationRepositoryImpl.java | 10 ++++++++++ .../spring/repository/note/NoteRepository.java | 11 +++++++++++ .../spring/repository/note/NoteRepositoryCustom.java | 4 ++++ .../spring/repository/note/NoteRepositoryImpl.java | 10 ++++++++++ .../spring/repository/price/PriceRepository.java | 9 +++++++++ .../repository/price/PriceRepositoryCustom.java | 4 ++++ .../spring/repository/price/PriceRepositoryImpl.java | 10 ++++++++++ .../spring/repository/season/SeasonRepository.java | 11 +++++++++++ .../repository/season/SeasonRepositoryCustom.java | 4 ++++ .../repository/season/SeasonRepositoryImpl.java | 10 ++++++++++ 33 files changed, 261 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepository.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragranceBaseNote/FragranceBaseNoteRepository.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragranceBaseNote/FragranceBaseNoteRepositoryCustom.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragranceBaseNote/FragranceBaseNoteRepositoryImpl.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragranceLocation/FragranceLocationRepository.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragranceLocation/FragranceLocationRepositoryCustom.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragranceLocation/FragranceLocationRepositoryImpl.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragranceMiddleNote/FragranceMiddleNoteRepository.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryCustom.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryImpl.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepository.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepositoryCustom.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepositoryImpl.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragranceSeason/FragranceSeasonRepository.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragranceSeason/FragranceSeasonRepositoryCustom.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragranceSeason/FragranceSeasonRepositoryImpl.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragranceTopNote/FragranceTopNoteRepository.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragranceTopNote/FragranceTopNoteRepositoryCustom.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragranceTopNote/FragranceTopNoteRepositoryImpl.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/location/LocationRepository.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/location/LocationRepositoryCustom.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/location/LocationRepositoryImpl.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/note/NoteRepository.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/note/NoteRepositoryCustom.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/note/NoteRepositoryImpl.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/price/PriceRepository.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/price/PriceRepositoryCustom.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/price/PriceRepositoryImpl.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/season/SeasonRepository.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/season/SeasonRepositoryCustom.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/season/SeasonRepositoryImpl.java diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepository.java b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepository.java new file mode 100644 index 0000000..50d4529 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepository.java @@ -0,0 +1,11 @@ +package PerfumeOnMe.spring.repository.fragrance; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.domain.Fragrance; + +public interface FragranceRepository extends JpaRepository, FragranceRepositoryCustom { + Optional findByName(String name); +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java new file mode 100644 index 0000000..5d5591f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.repository.fragrance; + +public interface FragranceRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java new file mode 100644 index 0000000..70c269e --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.repository.fragrance; + +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class FragranceRepositoryImpl implements FragranceRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceBaseNote/FragranceBaseNoteRepository.java b/src/main/java/PerfumeOnMe/spring/repository/fragranceBaseNote/FragranceBaseNoteRepository.java new file mode 100644 index 0000000..6417c67 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/fragranceBaseNote/FragranceBaseNoteRepository.java @@ -0,0 +1,9 @@ +package PerfumeOnMe.spring.repository.fragranceBaseNote; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.domain.mapping.FragranceBaseNote; + +public interface FragranceBaseNoteRepository + extends JpaRepository, FragranceBaseNoteRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceBaseNote/FragranceBaseNoteRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/fragranceBaseNote/FragranceBaseNoteRepositoryCustom.java new file mode 100644 index 0000000..8106888 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/fragranceBaseNote/FragranceBaseNoteRepositoryCustom.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.repository.fragranceBaseNote; + +public interface FragranceBaseNoteRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceBaseNote/FragranceBaseNoteRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/repository/fragranceBaseNote/FragranceBaseNoteRepositoryImpl.java new file mode 100644 index 0000000..81856b5 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/fragranceBaseNote/FragranceBaseNoteRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.repository.fragranceBaseNote; + +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class FragranceBaseNoteRepositoryImpl implements FragranceBaseNoteRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceLocation/FragranceLocationRepository.java b/src/main/java/PerfumeOnMe/spring/repository/fragranceLocation/FragranceLocationRepository.java new file mode 100644 index 0000000..73b487b --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/fragranceLocation/FragranceLocationRepository.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.repository.fragranceLocation; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.domain.mapping.FragranceLocation; + +public interface FragranceLocationRepository + extends JpaRepository, FragranceLocationRepositoryCustom { +} + diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceLocation/FragranceLocationRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/fragranceLocation/FragranceLocationRepositoryCustom.java new file mode 100644 index 0000000..fd2aaa3 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/fragranceLocation/FragranceLocationRepositoryCustom.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.repository.fragranceLocation; + +public interface FragranceLocationRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceLocation/FragranceLocationRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/repository/fragranceLocation/FragranceLocationRepositoryImpl.java new file mode 100644 index 0000000..8b43a74 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/fragranceLocation/FragranceLocationRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.repository.fragranceLocation; + +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class FragranceLocationRepositoryImpl implements FragranceLocationRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceMiddleNote/FragranceMiddleNoteRepository.java b/src/main/java/PerfumeOnMe/spring/repository/fragranceMiddleNote/FragranceMiddleNoteRepository.java new file mode 100644 index 0000000..2767fd4 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/fragranceMiddleNote/FragranceMiddleNoteRepository.java @@ -0,0 +1,9 @@ +package PerfumeOnMe.spring.repository.fragranceMiddleNote; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.domain.mapping.FragranceMiddleNote; + +public interface FragranceMiddleNoteRepository + extends JpaRepository, FragranceMiddleNoteRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryCustom.java new file mode 100644 index 0000000..4041aba --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryCustom.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.repository.fragranceMiddleNote; + +public interface FragranceMiddleNoteRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryImpl.java new file mode 100644 index 0000000..d484de3 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.repository.fragranceMiddleNote; + +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class FragranceMiddleNoteRepositoryImpl implements FragranceMiddleNoteRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepository.java b/src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepository.java new file mode 100644 index 0000000..d82deda --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepository.java @@ -0,0 +1,8 @@ +package PerfumeOnMe.spring.repository.fragrancePrice; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.domain.mapping.FragrancePrice; + +public interface FragrancePriceRepository extends JpaRepository, FragrancePriceRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepositoryCustom.java new file mode 100644 index 0000000..4ff8943 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepositoryCustom.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.repository.fragrancePrice; + +public interface FragrancePriceRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepositoryImpl.java new file mode 100644 index 0000000..7756a39 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.repository.fragrancePrice; + +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class FragrancePriceRepositoryImpl implements FragrancePriceRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceSeason/FragranceSeasonRepository.java b/src/main/java/PerfumeOnMe/spring/repository/fragranceSeason/FragranceSeasonRepository.java new file mode 100644 index 0000000..363e371 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/fragranceSeason/FragranceSeasonRepository.java @@ -0,0 +1,9 @@ +package PerfumeOnMe.spring.repository.fragranceSeason; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.domain.mapping.FragranceSeason; + +public interface FragranceSeasonRepository + extends JpaRepository, FragranceSeasonRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceSeason/FragranceSeasonRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/fragranceSeason/FragranceSeasonRepositoryCustom.java new file mode 100644 index 0000000..85de18e --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/fragranceSeason/FragranceSeasonRepositoryCustom.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.repository.fragranceSeason; + +public interface FragranceSeasonRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceSeason/FragranceSeasonRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/repository/fragranceSeason/FragranceSeasonRepositoryImpl.java new file mode 100644 index 0000000..4d426de --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/fragranceSeason/FragranceSeasonRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.repository.fragranceSeason; + +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class FragranceSeasonRepositoryImpl implements FragranceSeasonRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceTopNote/FragranceTopNoteRepository.java b/src/main/java/PerfumeOnMe/spring/repository/fragranceTopNote/FragranceTopNoteRepository.java new file mode 100644 index 0000000..127277c --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/fragranceTopNote/FragranceTopNoteRepository.java @@ -0,0 +1,9 @@ +package PerfumeOnMe.spring.repository.fragranceTopNote; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.domain.mapping.FragranceTopNote; + +public interface FragranceTopNoteRepository + extends JpaRepository, FragranceTopNoteRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceTopNote/FragranceTopNoteRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/fragranceTopNote/FragranceTopNoteRepositoryCustom.java new file mode 100644 index 0000000..496e3a1 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/fragranceTopNote/FragranceTopNoteRepositoryCustom.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.repository.fragranceTopNote; + +public interface FragranceTopNoteRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceTopNote/FragranceTopNoteRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/repository/fragranceTopNote/FragranceTopNoteRepositoryImpl.java new file mode 100644 index 0000000..c1ec8d8 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/fragranceTopNote/FragranceTopNoteRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.repository.fragranceTopNote; + +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class FragranceTopNoteRepositoryImpl implements FragranceTopNoteRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/location/LocationRepository.java b/src/main/java/PerfumeOnMe/spring/repository/location/LocationRepository.java new file mode 100644 index 0000000..5e05d42 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/location/LocationRepository.java @@ -0,0 +1,11 @@ +package PerfumeOnMe.spring.repository.location; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.domain.Location; + +public interface LocationRepository extends JpaRepository, LocationRepositoryCustom { + Optional findByName(String name); +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/repository/location/LocationRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/location/LocationRepositoryCustom.java new file mode 100644 index 0000000..001f427 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/location/LocationRepositoryCustom.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.repository.location; + +public interface LocationRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/location/LocationRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/repository/location/LocationRepositoryImpl.java new file mode 100644 index 0000000..2d95831 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/location/LocationRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.repository.location; + +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class LocationRepositoryImpl implements LocationRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/note/NoteRepository.java b/src/main/java/PerfumeOnMe/spring/repository/note/NoteRepository.java new file mode 100644 index 0000000..4a833ad --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/note/NoteRepository.java @@ -0,0 +1,11 @@ +package PerfumeOnMe.spring.repository.note; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.domain.Note; + +public interface NoteRepository extends JpaRepository, NoteRepositoryCustom { + Optional findByName(String name); +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/note/NoteRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/note/NoteRepositoryCustom.java new file mode 100644 index 0000000..a919ab3 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/note/NoteRepositoryCustom.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.repository.note; + +public interface NoteRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/note/NoteRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/repository/note/NoteRepositoryImpl.java new file mode 100644 index 0000000..c09db9e --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/note/NoteRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.repository.note; + +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class NoteRepositoryImpl implements NoteRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/price/PriceRepository.java b/src/main/java/PerfumeOnMe/spring/repository/price/PriceRepository.java new file mode 100644 index 0000000..141ca56 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/price/PriceRepository.java @@ -0,0 +1,9 @@ +package PerfumeOnMe.spring.repository.price; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.domain.Price; + +public interface PriceRepository extends JpaRepository, PriceRepositoryCustom { + +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/repository/price/PriceRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/price/PriceRepositoryCustom.java new file mode 100644 index 0000000..ccfd2bb --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/price/PriceRepositoryCustom.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.repository.price; + +public interface PriceRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/price/PriceRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/repository/price/PriceRepositoryImpl.java new file mode 100644 index 0000000..65c5b4f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/price/PriceRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.repository.price; + +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class PriceRepositoryImpl implements PriceRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/season/SeasonRepository.java b/src/main/java/PerfumeOnMe/spring/repository/season/SeasonRepository.java new file mode 100644 index 0000000..2201726 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/season/SeasonRepository.java @@ -0,0 +1,11 @@ +package PerfumeOnMe.spring.repository.season; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.domain.Season; + +public interface SeasonRepository extends JpaRepository, SeasonRepositoryCustom { + Optional findByName(String name); +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/season/SeasonRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/season/SeasonRepositoryCustom.java new file mode 100644 index 0000000..94cd132 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/season/SeasonRepositoryCustom.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.repository.season; + +public interface SeasonRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/season/SeasonRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/repository/season/SeasonRepositoryImpl.java new file mode 100644 index 0000000..fcaa1e8 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/season/SeasonRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.repository.season; + +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class SeasonRepositoryImpl implements SeasonRepositoryCustom { +} From 105e37d0953c6dadf426287fd533b7b026038899 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 4 Jul 2025 15:49:21 +0900 Subject: [PATCH 035/339] =?UTF-8?q?[Fix]=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=82=AD=EC=A0=9C=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/domain/Fragrance.java | 12 +++--- .../spring/domain/FragranceKeyword.java | 42 ------------------- .../java/PerfumeOnMe/spring/domain/Note.java | 14 +++++-- .../{FragrancePrice.java => Price.java} | 19 +++++---- 4 files changed, 28 insertions(+), 59 deletions(-) delete mode 100644 src/main/java/PerfumeOnMe/spring/domain/FragranceKeyword.java rename src/main/java/PerfumeOnMe/spring/domain/{FragrancePrice.java => Price.java} (68%) diff --git a/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java b/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java index fabe557..dbd8c67 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java @@ -14,6 +14,7 @@ import PerfumeOnMe.spring.domain.mapping.FragranceBaseNote; import PerfumeOnMe.spring.domain.mapping.FragranceLocation; import PerfumeOnMe.spring.domain.mapping.FragranceMiddleNote; +import PerfumeOnMe.spring.domain.mapping.FragrancePrice; import PerfumeOnMe.spring.domain.mapping.FragranceSeason; import PerfumeOnMe.spring.domain.mapping.FragranceTopNote; import PerfumeOnMe.spring.domain.mapping.RecommendedFragrance; @@ -71,7 +72,7 @@ public class Fragrance extends BaseEntity { private FragranceType fragranceType; @Column(columnDefinition = "TEXT", nullable = false) - private String ImageURL; + private String imageURL; @Column(nullable = false) private String topNoteDescription; @@ -91,6 +92,9 @@ public class Fragrance extends BaseEntity { @Column(nullable = false) private String baseNoteKeyword; + @Column(nullable = false, unique = true, length = 30) + private String keyword; + //----- 매핑 관계 ----- @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) @@ -108,11 +112,7 @@ public class Fragrance extends BaseEntity { @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) @Builder.Default private List fragranceLocationList = new ArrayList<>(); - - @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) - @Builder.Default - private List fragranceKeywordList = new ArrayList<>(); - + @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) @Builder.Default private List fragrancePriceList = new ArrayList<>(); diff --git a/src/main/java/PerfumeOnMe/spring/domain/FragranceKeyword.java b/src/main/java/PerfumeOnMe/spring/domain/FragranceKeyword.java deleted file mode 100644 index 96be4ba..0000000 --- a/src/main/java/PerfumeOnMe/spring/domain/FragranceKeyword.java +++ /dev/null @@ -1,42 +0,0 @@ -package PerfumeOnMe.spring.domain; - -import org.hibernate.annotations.DynamicInsert; -import org.hibernate.annotations.DynamicUpdate; - -import PerfumeOnMe.spring.domain.base.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Getter -@Builder -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@DynamicInsert -@DynamicUpdate -@Table(name = "fragrance_keywords") -public class FragranceKeyword extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, unique = true, length = 20) - private String name; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "fragrance_id") - private Fragrance fragrance; - -} diff --git a/src/main/java/PerfumeOnMe/spring/domain/Note.java b/src/main/java/PerfumeOnMe/spring/domain/Note.java index fef293a..99a8bb3 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Note.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Note.java @@ -7,6 +7,7 @@ import org.hibernate.annotations.DynamicUpdate; import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.domain.enums.NoteType; import PerfumeOnMe.spring.domain.mapping.FragranceBaseNote; import PerfumeOnMe.spring.domain.mapping.FragranceMiddleNote; import PerfumeOnMe.spring.domain.mapping.FragranceTopNote; @@ -42,9 +43,6 @@ public class Note extends BaseEntity { @Column(nullable = false, unique = true, length = 40) private String name; - @Column(nullable = false) - private String description; - @Column(nullable = false) private boolean top; @@ -69,4 +67,14 @@ public class Note extends BaseEntity { @OneToMany(mappedBy = "note", cascade = CascadeType.ALL) @Builder.Default private List userNoteList = new ArrayList<>(); + + // 메서드 + // Setter 를 안쓰기 위해 메서드로 top, middle, base 구분 + public void activateType(NoteType type) { + switch (type) { + case TOP -> this.top = true; + case MIDDLE -> this.middle = true; + case BASE -> this.base = true; + } + } } diff --git a/src/main/java/PerfumeOnMe/spring/domain/FragrancePrice.java b/src/main/java/PerfumeOnMe/spring/domain/Price.java similarity index 68% rename from src/main/java/PerfumeOnMe/spring/domain/FragrancePrice.java rename to src/main/java/PerfumeOnMe/spring/domain/Price.java index 0624ec3..f362270 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/FragrancePrice.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Price.java @@ -1,17 +1,20 @@ package PerfumeOnMe.spring.domain; +import java.util.ArrayList; +import java.util.List; + import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.domain.mapping.FragrancePrice; +import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; +import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -26,8 +29,8 @@ @AllArgsConstructor @DynamicInsert @DynamicUpdate -@Table(name = "fragrance_prices") -public class FragrancePrice extends BaseEntity { +@Table(name = "prices") +public class Price extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -38,7 +41,7 @@ public class FragrancePrice extends BaseEntity { @Column(nullable = false) private int price; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "fragrance_id") - private Fragrance fragrance; + @OneToMany(mappedBy = "price", cascade = CascadeType.ALL) + @Builder.Default + private List fragrancePriceList = new ArrayList<>(); } From ad67a0dedcb118386e4959dc19683c52846ac311 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 4 Jul 2025 15:50:20 +0900 Subject: [PATCH 036/339] =?UTF-8?q?[Feature]=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EC=8B=9C=ED=8A=B8=20=EC=A0=80=EC=9E=A5=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/FragranceImportService.java | 128 ++++++++++ .../spring/service/FragranceRowProcessor.java | 238 ++++++++++++++++++ 2 files changed, 366 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/service/FragranceImportService.java create mode 100644 src/main/java/PerfumeOnMe/spring/service/FragranceRowProcessor.java diff --git a/src/main/java/PerfumeOnMe/spring/service/FragranceImportService.java b/src/main/java/PerfumeOnMe/spring/service/FragranceImportService.java new file mode 100644 index 0000000..dac0483 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/FragranceImportService.java @@ -0,0 +1,128 @@ +package PerfumeOnMe.spring.service; + +import java.io.InputStream; + +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.CellType; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Service; + +import PerfumeOnMe.spring.domain.Price; +import PerfumeOnMe.spring.domain.mapping.FragrancePrice; +import PerfumeOnMe.spring.repository.fragrance.FragranceRepository; +import PerfumeOnMe.spring.repository.fragrancePrice.FragrancePriceRepository; +import PerfumeOnMe.spring.repository.price.PriceRepository; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; + +/** + * 엑셀로부터 향수 정보를 불러와 DB에 저장하는 서비스 클래스 + * - 향수 정보는 "향수정보" 시트에서 가져오며, FragranceRowProcessor를 통해 처리 + * - 가격 정보는 "가격" 시트에서 직접 처리하여 저장 + * - @PostConstruct 어노테이션을 통해 서버 구동 시 자동 실행 + */ + +@Service +@RequiredArgsConstructor +public class FragranceImportService { + + private final FragranceRepository fragranceRepository; + private final FragranceRowProcessor fragranceRowProcessor; + private final FragrancePriceRepository fragrancePriceRepository; + private final PriceRepository priceRepository; + + /** + * 애플리케이션 실행 시 엑셀 파일을 읽고 전체 데이터를 DB에 로드함 + */ + @PostConstruct + public void init() throws Exception { + InputStream is = new ClassPathResource( + "data/perfumeOnMe_data.xlsx").getInputStream(); // resources 폴더에서 엑셀 파일 로드 + + Workbook workbook = WorkbookFactory.create(is); // 엑셀 Workbook 객체 생성 + importAllFromWorkbook(workbook); + System.out.println("✅ 향수 엑셀 데이터 로드 완료!"); + } + + /** + * 엑셀 파일 시트를 순회하며 향수 정보와 가격 정보를 각각 처리 + * 엑셀 파일에서 밑에 있는 향수정보 시트와 가격 시트를 각각 가져옴 + */ + public void importAllFromWorkbook(Workbook workbook) { + Sheet infoSheet = workbook.getSheet("향수정보"); + for (Row row : infoSheet) { + if (row.getRowNum() == 0 || isRowEmpty(row)) // 첫 줄(헤더)이거나 비어있는 행은 건너뜀 + continue; + fragranceRowProcessor.importFragranceFromExcelRow(row); // 향수 정보 저장 + } + + Sheet priceSheet = workbook.getSheet("가격"); + for (Row row : priceSheet) { + if (row.getRowNum() == 0 || isRowEmpty(row)) // 첫 줄(헤더)이거나 비어있는 행은 건너뜀 + continue; + importPriceFromRow(row); // 가격 정보 저장 + } + } + + /** + * 개별 가격 Row 를 처리하여 FragrancePrice 및 Price 엔티티를 저장 + */ + private void importPriceFromRow(Row row) { + String idStr = getCellValue(row, 0); // perfume_id + String mlStr = getCellValue(row, 1); // ml 용량 + String priceStr = getCellValue(row, 2); // price(가격) + + try { + Long perfumeId = (long)Double.parseDouble(idStr); + int mlCount = (int)Double.parseDouble(mlStr); + int price = (int)Double.parseDouble(priceStr); + + // 향수가 존재할 경우 가격 및 매핑 정보 저장 + fragranceRepository.findById(perfumeId).ifPresent(fragrance -> { + Price savedPrice = priceRepository.save( + Price.builder() + .mlCount(mlCount) + .price(price) + .build() + ); + + fragrancePriceRepository.save( + FragrancePrice.builder() + .fragrance(fragrance) + .price(savedPrice) + .build() + ); + }); + } catch (NumberFormatException e) { + System.out.println("⚠️ 가격 row 변환 실패: " + e.getMessage()); // 파싱 실패 로그 + } + } + + /** + * 셀에서 문자열 값을 안전하게 가져오는 유틸 메서드 + * row = 행, index = 열 + */ + private String getCellValue(Row row, int index) { + Cell cell = row.getCell(index); + return cell == null ? "" : cell.toString().trim(); + } + + /** + * 행이 비어있는지 여부를 판단 (모든 셀이 비어있거나 BLANK 인 경우 true) + */ + private boolean isRowEmpty(Row row) { + if (row == null) + return true; + for (int c = 0; c < row.getLastCellNum(); c++) { + Cell cell = row.getCell(c); + if (cell != null && cell.getCellType() != CellType.BLANK && !cell.toString().trim().isEmpty()) { + return false; + } + } + return true; + } +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/service/FragranceRowProcessor.java b/src/main/java/PerfumeOnMe/spring/service/FragranceRowProcessor.java new file mode 100644 index 0000000..08675ce --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/FragranceRowProcessor.java @@ -0,0 +1,238 @@ +package PerfumeOnMe.spring.service; + +import java.util.Arrays; + +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.Row; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import PerfumeOnMe.spring.domain.Fragrance; +import PerfumeOnMe.spring.domain.Location; +import PerfumeOnMe.spring.domain.Note; +import PerfumeOnMe.spring.domain.Season; +import PerfumeOnMe.spring.domain.enums.Brand; +import PerfumeOnMe.spring.domain.enums.FragranceGender; +import PerfumeOnMe.spring.domain.enums.FragranceType; +import PerfumeOnMe.spring.domain.enums.NoteType; +import PerfumeOnMe.spring.domain.mapping.FragranceBaseNote; +import PerfumeOnMe.spring.domain.mapping.FragranceLocation; +import PerfumeOnMe.spring.domain.mapping.FragranceMiddleNote; +import PerfumeOnMe.spring.domain.mapping.FragranceSeason; +import PerfumeOnMe.spring.domain.mapping.FragranceTopNote; +import PerfumeOnMe.spring.repository.fragrance.FragranceRepository; +import PerfumeOnMe.spring.repository.fragranceBaseNote.FragranceBaseNoteRepository; +import PerfumeOnMe.spring.repository.fragranceLocation.FragranceLocationRepository; +import PerfumeOnMe.spring.repository.fragranceMiddleNote.FragranceMiddleNoteRepository; +import PerfumeOnMe.spring.repository.fragranceSeason.FragranceSeasonRepository; +import PerfumeOnMe.spring.repository.fragranceTopNote.FragranceTopNoteRepository; +import PerfumeOnMe.spring.repository.location.LocationRepository; +import PerfumeOnMe.spring.repository.note.NoteRepository; +import PerfumeOnMe.spring.repository.season.SeasonRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class FragranceRowProcessor { + + // 필요한 Repository 들 의존성 주입 + private final FragranceRepository fragranceRepository; + private final NoteRepository noteRepository; + private final FragranceTopNoteRepository fragranceTopNoteRepository; + private final FragranceMiddleNoteRepository fragranceMiddleNoteRepository; + private final FragranceBaseNoteRepository fragranceBaseNoteRepository; + private final LocationRepository locationRepository; + private final FragranceLocationRepository fragranceLocationRepository; + private final SeasonRepository seasonRepository; + private final FragranceSeasonRepository fragranceSeasonRepository; + + /** + * 엑셀 한 행(Row)의 향수 데이터를 읽어 DB에 저장하는 핵심 메서드 + * @Transactional : 한 행 단위로 트랜잭션 보장 + */ + @Transactional + public void importFragranceFromExcelRow(Row row) { + String name = getCellValue(row, 1); + + // 이미 저장된 향수라면 중복 저장 방지 + if (fragranceRepository.findByName(name).isPresent()) { + System.out.println("⚠️ 이미 존재하는 향수: " + name + " → 저장하지 않음"); + return; + } + + // 엑셀 각 셀 데이터 추출 + String brandStr = getCellValue(row, 2); + String keywordStr = getCellValue(row, 4); + String topNotesStr = getCellValue(row, 5); + String topNoteKeyword = getCellValue(row, 6); + String topNoteDescription = getCellValue(row, 7); + String middleNotesStr = getCellValue(row, 8); + String middleNoteKeyword = getCellValue(row, 9); + String middleNoteDescription = getCellValue(row, 10); + String baseNotesStr = getCellValue(row, 11); + String baseNoteKeyword = getCellValue(row, 12); + String baseNoteDescription = getCellValue(row, 13); + String description = getCellValue(row, 14); + String genderStr = getCellValue(row, 15); + String locationStr = getCellValue(row, 16); + String seasonStr = getCellValue(row, 17); + String homepage = getCellValue(row, 18); + String typeStr = getCellValue(row, 19); + String imageUrl = getCellValue(row, 20); + + // 문자열을 enum 으로 변환 + Brand brand = convertToBrand(brandStr); + FragranceGender gender = convertToFragranceGender(genderStr); + FragranceType type = convertType(typeStr); + + // Fragrance 엔티티 생성 및 저장 + Fragrance fragrance = Fragrance.builder() + .name(name) + .brand(brand) + .description(description) + .gender(gender) + .fragranceType(type) + .homePageURL(homepage) + .imageURL(imageUrl) + .keyword(keywordStr) + .topNoteKeyword(topNoteKeyword) + .middleNoteKeyword(middleNoteKeyword) + .baseNoteKeyword(baseNoteKeyword) + .topNoteDescription(topNoteDescription) + .middleNoteDescription(middleNoteDescription) + .baseNoteDescription(baseNoteDescription) + .build(); + + fragranceRepository.save(fragrance); + + // 향수와 각 노트/계절/장소 정보 연결 + saveNotes(fragrance, topNotesStr, NoteType.TOP); // 탑 노트 + saveNotes(fragrance, middleNotesStr, NoteType.MIDDLE); // 미들 노트 + saveNotes(fragrance, baseNotesStr, NoteType.BASE); // 베이스 노트 + saveLocations(fragrance, locationStr); + saveSeasons(fragrance, seasonStr); + } + + private String getCellValue(Row row, int index) { + Cell cell = row.getCell(index); + return cell == null ? "" : cell.toString().trim(); + } + + /** + * 노트 문자열 리스트(noteStr)를 파싱하고 + * Note 테이블과 각 노트 매핑 테이블(top, middle, base)에 저장 + */ + private void saveNotes(Fragrance fragrance, String noteStr, NoteType type) { + Arrays.stream(noteStr.split(",")) + .map(String::trim) + .filter(n -> !n.isEmpty()) + .forEach(noteName -> { + Note note = noteRepository.findByName(noteName).orElse(null); + if (note == null) { + // Note 가 없으면 새로 저장 + note = Note.builder() + .name(noteName) + .top(type == NoteType.TOP) // 해당 노트가 TOP 이면 true + .middle(type == NoteType.MIDDLE) // 해당 노트가 MIDDLE 이면 true + .base(type == NoteType.BASE) // 해당 노트가 BASE 이면 true + .build(); + } else { // 이미 해당 노트가 존재하다면 해당 노트에 대한 필드 활성화만 0 -> 1 + + // builder()는 객체를 새로 만들 때만 사용함. + // 이미 존재하는 Note 객체를 수정하는 것은 builder()로 수정 불가 + // 따라서 note 테이블에서 activateType() 메서드를 만들어서 필드 수정 + note.activateType(type); + } + noteRepository.save(note); + + // 매핑 테이블 저장 + if (type == NoteType.TOP) { + fragranceTopNoteRepository.save(FragranceTopNote.builder().fragrance(fragrance).note(note).build()); + } else if (type == NoteType.MIDDLE) { + fragranceMiddleNoteRepository.save( + FragranceMiddleNote.builder().fragrance(fragrance).note(note).build()); + } else { + fragranceBaseNoteRepository.save( + FragranceBaseNote.builder().fragrance(fragrance).note(note).build()); + } + }); + } + + /** + * 장소 테이블과 향수-장소 관계 매핑 테이블 저장 + */ + private void saveLocations(Fragrance fragrance, String locationStr) { + Arrays.stream(locationStr.split(",")) + .map(String::trim) + .filter(loc -> !loc.isEmpty()) + .distinct() + .forEach(locName -> { + Location location = locationRepository.findByName(locName) + .orElseGet(() -> locationRepository.save(Location.builder().name(locName).build())); + fragranceLocationRepository.save( + FragranceLocation.builder().fragrance(fragrance).location(location).build() + ); + }); + } + + /** + * 계절 테이블과 향수-계절 관계 매핑 테이블 저장 + */ + private void saveSeasons(Fragrance fragrance, String seasonStr) { + Arrays.stream(seasonStr.split(",")) + .map(String::trim) + .filter(season -> !season.isEmpty()) + .distinct() + .forEach(seasonName -> { + Season season = seasonRepository.findByName(seasonName) + .orElseGet(() -> seasonRepository.save(Season.builder().name(seasonName).build())); + fragranceSeasonRepository.save( + FragranceSeason.builder().fragrance(fragrance).season(season).build() + ); + }); + } + + /** + * Enum 변환 유틸 메서드들 + */ + /* + MAISON MARGIELA , FREDERIC MALLE 는 엑셀 시트에 "_" 언더바 표시가 아닌 + 띄어씌기로 되어있어서 Brand enum 타입에 맞게 변환 + * */ + private Brand convertToBrand(String brandStr) { + return switch (brandStr.trim().toUpperCase()) { + case "MAISON MARGIELA" -> Brand.MAISON_MARGIELA; + case "FREDERIC MALLE" -> Brand.FREDERIC_MALLE; + case "LOIVIE" -> Brand.LOIVIE; + case "DIPTYQUE" -> Brand.DIPTYQUE; + case "JOMALONE" -> Brand.JOMALONE; + default -> throw new IllegalArgumentException("지원하지 않는 브랜드: " + brandStr); + }; + } + + /* + 엑셀 시트에 남성용, 여성용, 남녀불문 이라고 저장되어 있기 때문에 + FragranceGender enum 타입에 맞게 변환 + * */ + private FragranceGender convertToFragranceGender(String genderStr) { + return switch (genderStr) { + case "남성용" -> FragranceGender.MALE; + case "여성용" -> FragranceGender.FEMALE; + default -> FragranceGender.NEUTRAL; + }; + } + + /* + 향수 타입도 마찬가지로 FragranceType 에 맞게 변환 + * */ + private FragranceType convertType(String typeStr) { + return switch (typeStr) { + case "퍼퓸" -> FragranceType.PERFUME; + case "오 드 퍼퓸" -> FragranceType.EAU_DE_PERFUME; + case "오 드 뚜왈렛" -> FragranceType.EAU_DE_TOILETTE; + case "오 드 코롱" -> FragranceType.EAU_DE_COLOGNE; + case "샤워 코롱" -> FragranceType.SHOWER_COLOGNE; + default -> null; + }; + } +} From c31be461f3dac03cad85d7a7a2f01d8144ed19ae Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 4 Jul 2025 15:50:58 +0900 Subject: [PATCH 037/339] =?UTF-8?q?[Fix]=20enum=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apiPayload/code/status/ErrorStatus.java | 59 ++++++++++--------- .../spring/domain/enums/Brand.java | 22 ++++++- .../spring/domain/enums/NoteType.java | 5 ++ 3 files changed, 58 insertions(+), 28 deletions(-) create mode 100644 src/main/java/PerfumeOnMe/spring/domain/enums/NoteType.java diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index 1e9fa95..59a4ea7 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -1,40 +1,45 @@ package PerfumeOnMe.spring.apiPayload.code.status; +import org.springframework.http.HttpStatus; + import PerfumeOnMe.spring.apiPayload.code.BaseErrorCode; import PerfumeOnMe.spring.apiPayload.code.ErrorReasonDTO; import lombok.AllArgsConstructor; import lombok.Getter; -import org.springframework.http.HttpStatus; @Getter @AllArgsConstructor public enum ErrorStatus implements BaseErrorCode { - // 예시,,, - ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); - - private final HttpStatus httpStatus; - private final String code; - private final String message; - - @Override - public ErrorReasonDTO getReason() { - return ErrorReasonDTO.builder() - .message(message) - .code(code) - .isSuccess(false) - .build(); - } - - @Override - public ErrorReasonDTO getReasonHttpStatus() { - return ErrorReasonDTO.builder() - .message(message) - .code(code) - .isSuccess(false) - .httpStatus(httpStatus) - .build() - ; - } + //일반적인 에러 + _INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON500", "서버 에러, 관리자에게 문의 바랍니다."), + _BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON400", "잘못된 요청입니다."), + _UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401", "인증이 필요합니다."), + _FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403", "금지된 요청입니다."), + ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDTO getReason() { + return ErrorReasonDTO.builder() + .message(message) + .code(code) + .isSuccess(false) + .build(); + } + + @Override + public ErrorReasonDTO getReasonHttpStatus() { + return ErrorReasonDTO.builder() + .message(message) + .code(code) + .isSuccess(false) + .httpStatus(httpStatus) + .build() + ; + } } diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Brand.java b/src/main/java/PerfumeOnMe/spring/domain/enums/Brand.java index cf9cd68..dde1a46 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/Brand.java +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/Brand.java @@ -1,5 +1,25 @@ package PerfumeOnMe.spring.domain.enums; +import java.util.Arrays; + public enum Brand { - LOIVIE, DIPTYQUE, JOMALONE, MAISON_MARGIELA, FREDERIC_MALLE + LOIVIE, + DIPTYQUE, + JOMALONE, + MAISON_MARGIELA, + FREDERIC_MALLE; + + public static Brand fromString(String input) { + if (input == null) { + throw new IllegalArgumentException("브랜드명이 null입니다."); + } + + // "MAISON MARGIELA" → "MAISON_MARGIELA" + String normalized = input.trim().toUpperCase().replace(" ", "_"); + + return Arrays.stream(values()) + .filter(b -> b.name().equals(normalized)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unknown brand: " + input)); + } } diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/NoteType.java b/src/main/java/PerfumeOnMe/spring/domain/enums/NoteType.java new file mode 100644 index 0000000..21bcc6f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/NoteType.java @@ -0,0 +1,5 @@ +package PerfumeOnMe.spring.domain.enums; + +public enum NoteType { + TOP, MIDDLE, BASE +} \ No newline at end of file From cd193818591928d9608340583ffab9fc08fd097e Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 4 Jul 2025 15:51:27 +0900 Subject: [PATCH 038/339] =?UTF-8?q?[Feature]=20=ED=96=A5=EC=88=98=20-=20?= =?UTF-8?q?=EA=B0=80=EA=B2=A9=20=EB=8B=A4=EB=8C=80=EB=8B=A4=20=EA=B4=80?= =?UTF-8?q?=EA=B3=84=20=EB=A7=A4=ED=95=91=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/domain/mapping/FragrancePrice.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/domain/mapping/FragrancePrice.java diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragrancePrice.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragrancePrice.java new file mode 100644 index 0000000..c542deb --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragrancePrice.java @@ -0,0 +1,42 @@ +package PerfumeOnMe.spring.domain.mapping; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +import PerfumeOnMe.spring.domain.Fragrance; +import PerfumeOnMe.spring.domain.Price; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@DynamicInsert +@DynamicUpdate +@Table(name = "fragrance_prices") +public class FragrancePrice { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "price_id") + private Price price; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "fragrance_id") + private Fragrance fragrance; +} From da85f15de5b09dcaf2271e98564f20f5b7d839f3 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 4 Jul 2025 15:51:40 +0900 Subject: [PATCH 039/339] =?UTF-8?q?[Feature]=20=EC=A0=80=EC=9E=A5=ED=95=A0?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EC=8B=9C=ED=8A=B8=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/data/perfumeOnMe_data.xlsx | Bin 0 -> 124259 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/main/resources/data/perfumeOnMe_data.xlsx diff --git a/src/main/resources/data/perfumeOnMe_data.xlsx b/src/main/resources/data/perfumeOnMe_data.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..2d7810b4ceff3e5fb256b559772d76a8bf97a440 GIT binary patch literal 124259 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 literal 0 HcmV?d00001 From d67de52f91af81e482567166b3763688d5d80e8a Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 4 Jul 2025 15:52:53 +0900 Subject: [PATCH 040/339] =?UTF-8?q?[Feature]=20application.yml=20=ED=94=84?= =?UTF-8?q?=EB=A1=9C=ED=95=84=EC=9D=84=20local(.gitignore)=EA=B3=BC=20dev(?= =?UTF-8?q?default)=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ src/main/resources/application-dev.yml | 22 ++++++++++++++++++++++ src/main/resources/application.yml | 3 +++ 3 files changed, 28 insertions(+) create mode 100644 src/main/resources/application-dev.yml diff --git a/.gitignore b/.gitignore index c2065bc..125675a 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,6 @@ out/ ### VS Code ### .vscode/ + +### application-local.yml ### +src/main/resources/application-local.yml diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..bedcaec --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,22 @@ +spring: + config: + activate: + on-profile: dev + datasource: + url: # RDS + username: # Username + password: # Password + driver-class-name: com.mysql.cj.jdbc.Driver + sql: + init: + mode: never + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + dialect: org.hibernate.dialect.MySQL8Dialect + format_sql: true + use_sql_comments: true + default_batch_fetch_size: 1000 + show-sql: true \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index e69de29..543df73 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -0,0 +1,3 @@ +spring: + profiles: + default: dev \ No newline at end of file From 4c0b11e7005e2cfee3788d5a80d8cbf87ac2c3d6 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 4 Jul 2025 15:55:11 +0900 Subject: [PATCH 041/339] =?UTF-8?q?[Fix]=20QueryDSL=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EC=B6=A9=EB=8F=8C=20=ED=95=B4=EA=B2=B0=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 63 +++++-------------- .../spring/web/controller/RootController.java | 10 +-- 2 files changed, 19 insertions(+), 54 deletions(-) diff --git a/build.gradle b/build.gradle index 843f36f..516b5c7 100644 --- a/build.gradle +++ b/build.gradle @@ -17,17 +17,12 @@ configurations { compileOnly { extendsFrom annotationProcessor } - querydsl {} } repositories { mavenCentral() } -ext { - querydslVersion = '5.1.0' -} - dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' @@ -41,8 +36,8 @@ dependencies { runtimeOnly 'com.mysql:mysql-connector-j' // ✅ QueryDSL (5.1.0) - implementation "com.querydsl:querydsl-jpa:$querydslVersion:jakarta" - annotationProcessor "com.querydsl:querydsl-apt:$querydslVersion:jakarta" + implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" @@ -52,7 +47,7 @@ dependencies { // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' - // validation + // Validation implementation 'org.springframework.boot:spring-boot-starter-validation' // Security implementation 'org.springframework.boot:spring-boot-starter-security' @@ -64,51 +59,21 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'org.apache.commons:commons-pool2' // 커넥션 풀링 + // OAuth + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + // Query Parameter Log + implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' + //Apache POI + implementation 'org.apache.poi:poi-ooxml:5.2.3' } tasks.named('test') { useJUnitPlatform() } -// QueryDSL 설정부 -def querydslDir = file("src/main/generated") - -sourceSets { - main { - java { - srcDirs += querydslDir - } - } -} - -// Q 클래스 중복 생성 방지 및 자동 생성 -tasks.named('compileJava') { - doFirst { - if (querydslDir.exists()) { - querydslDir.deleteDir() // Q 클래스 중복 생성 방지 - } - querydslDir.mkdirs() - } -} - -tasks.register("compileQuerydsl", JavaCompile) { - group = "build" - description = "Generates the QueryDSL Q-types" - source = sourceSets.main.java - destinationDirectory = querydslDir - classpath = sourceSets.main.compileClasspath - options.annotationProcessorPath = configurations.annotationProcessor - options.compilerArgs = [ - '-proc:only', - '-processor', 'com.querydsl.apt.jpa.JPAAnnotationProcessor' - ] -} - -// compileJava는 항상 compileQuerydsl 이후에 실행 -compileJava { - dependsOn compileQuerydsl -} - -jar { - enabled = false +clean { + delete file('src/main/generated') } \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/RootController.java b/src/main/java/PerfumeOnMe/spring/web/controller/RootController.java index 2654d7c..76ae080 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/RootController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/RootController.java @@ -6,8 +6,8 @@ @RestController public class RootController { - @GetMapping("/health") - public String healthCheck(){ - return "I'm really healthy"; - } -} \ No newline at end of file + @GetMapping("/health") + public String healthCheck() { + return "I'm really healthy"; + } +} From e6c23319272c276065af7c6bd8444df0421fc8a8 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Sat, 5 Jul 2025 22:55:49 +0900 Subject: [PATCH 042/339] =?UTF-8?q?[Fix]=20PR=20Merge=20=EC=B6=A9=EB=8F=8C?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/data/.DS_Store | Bin 0 -> 6148 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 src/main/resources/data/.DS_Store diff --git a/src/main/resources/data/.DS_Store b/src/main/resources/data/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5008ddfcf53c02e82d7eee2e57c38e5672ef89f6 GIT binary patch literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0 Date: Sun, 6 Jul 2025 03:15:12 +0900 Subject: [PATCH 043/339] =?UTF-8?q?[Fix]=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EC=8B=9C=ED=8A=B8=20=EA=B4=80=EB=A0=A8=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=EB=A5=BC=20=EC=9C=84=ED=95=B4=20ErrorStatus?= =?UTF-8?q?=20=EC=97=90=20=EA=B4=80=EB=A0=A8=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/apiPayload/code/status/ErrorStatus.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index 448c9f3..f19bf72 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -21,6 +21,11 @@ public enum ErrorStatus implements BaseErrorCode { LOGIN_ID_DUPLICATE(HttpStatus.BAD_REQUEST, "MEMBER4001", "이미 사용된 아이디입니다."), PASSWORD_CONFIRM_FAIL(HttpStatus.BAD_REQUEST, "MEMBER4002", "비밀번호 확인을 실패했습니다."), + // 데이터시트 에러 + UNSUPPORTED_BRAND(HttpStatus.BAD_REQUEST, "DATA4001", "지원하지 않는 브랜드입니다."), + UNSUPPORTED_TYPE(HttpStatus.BAD_REQUEST, "DATA4002", "지원하지 않는 향수타입입니다."), + PRICE_PARSING_ERROR(HttpStatus.BAD_REQUEST, "DATA4003", "가격 정보를 숫자로 변환할 수 없습니다."), + // 예시,,, ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); From 6c94ac88ba13c4987cc79cc9f62752eedd41f15a Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Sun, 6 Jul 2025 03:16:29 +0900 Subject: [PATCH 044/339] =?UTF-8?q?[Fix]=20prices=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=EC=9D=98=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=9C=84=ED=95=B4=20findByMlCountAndPrice()=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/price/PriceRepository.java | 4 +++- .../service/FragranceImportService.java | 22 ++++++++++++------- .../spring/service/FragranceRowProcessor.java | 10 ++++++--- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/repository/price/PriceRepository.java b/src/main/java/PerfumeOnMe/spring/repository/price/PriceRepository.java index 141ca56..5e52039 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/price/PriceRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/price/PriceRepository.java @@ -1,9 +1,11 @@ package PerfumeOnMe.spring.repository.price; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import PerfumeOnMe.spring.domain.Price; public interface PriceRepository extends JpaRepository, PriceRepositoryCustom { - + Optional findByMlCountAndPrice(Integer mlCount, Integer price); } \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/service/FragranceImportService.java b/src/main/java/PerfumeOnMe/spring/service/FragranceImportService.java index dac0483..442eece 100644 --- a/src/main/java/PerfumeOnMe/spring/service/FragranceImportService.java +++ b/src/main/java/PerfumeOnMe/spring/service/FragranceImportService.java @@ -11,6 +11,8 @@ import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; import PerfumeOnMe.spring.domain.Price; import PerfumeOnMe.spring.domain.mapping.FragrancePrice; import PerfumeOnMe.spring.repository.fragrance.FragranceRepository; @@ -18,6 +20,7 @@ import PerfumeOnMe.spring.repository.price.PriceRepository; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; /** * 엑셀로부터 향수 정보를 불러와 DB에 저장하는 서비스 클래스 @@ -28,6 +31,7 @@ @Service @RequiredArgsConstructor +@Slf4j public class FragranceImportService { private final FragranceRepository fragranceRepository; @@ -45,7 +49,7 @@ public void init() throws Exception { Workbook workbook = WorkbookFactory.create(is); // 엑셀 Workbook 객체 생성 importAllFromWorkbook(workbook); - System.out.println("✅ 향수 엑셀 데이터 로드 완료!"); + log.info("✅향수 엑셀 데이터 로드 완료!"); } /** @@ -83,12 +87,14 @@ private void importPriceFromRow(Row row) { // 향수가 존재할 경우 가격 및 매핑 정보 저장 fragranceRepository.findById(perfumeId).ifPresent(fragrance -> { - Price savedPrice = priceRepository.save( - Price.builder() - .mlCount(mlCount) - .price(price) - .build() - ); + // 동일한 ml, 가격이 이미 존재하는지 확인 + Price savedPrice = priceRepository.findByMlCountAndPrice(mlCount, price) + .orElseGet(() -> priceRepository.save( + Price.builder() + .mlCount(mlCount) + .price(price) + .build() + )); fragrancePriceRepository.save( FragrancePrice.builder() @@ -98,7 +104,7 @@ private void importPriceFromRow(Row row) { ); }); } catch (NumberFormatException e) { - System.out.println("⚠️ 가격 row 변환 실패: " + e.getMessage()); // 파싱 실패 로그 + throw new GeneralException(ErrorStatus.PRICE_PARSING_ERROR); // 파싱 실패 로그 } } diff --git a/src/main/java/PerfumeOnMe/spring/service/FragranceRowProcessor.java b/src/main/java/PerfumeOnMe/spring/service/FragranceRowProcessor.java index 08675ce..566480f 100644 --- a/src/main/java/PerfumeOnMe/spring/service/FragranceRowProcessor.java +++ b/src/main/java/PerfumeOnMe/spring/service/FragranceRowProcessor.java @@ -7,6 +7,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; import PerfumeOnMe.spring.domain.Fragrance; import PerfumeOnMe.spring.domain.Location; import PerfumeOnMe.spring.domain.Note; @@ -30,9 +32,11 @@ import PerfumeOnMe.spring.repository.note.NoteRepository; import PerfumeOnMe.spring.repository.season.SeasonRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; @Service @RequiredArgsConstructor +@Slf4j public class FragranceRowProcessor { // 필요한 Repository 들 의존성 주입 @@ -56,7 +60,7 @@ public void importFragranceFromExcelRow(Row row) { // 이미 저장된 향수라면 중복 저장 방지 if (fragranceRepository.findByName(name).isPresent()) { - System.out.println("⚠️ 이미 존재하는 향수: " + name + " → 저장하지 않음"); + log.info("⚠️이미 존재하는 향수: " + name + " → 저장하지 않음"); return; } @@ -206,7 +210,7 @@ private Brand convertToBrand(String brandStr) { case "LOIVIE" -> Brand.LOIVIE; case "DIPTYQUE" -> Brand.DIPTYQUE; case "JOMALONE" -> Brand.JOMALONE; - default -> throw new IllegalArgumentException("지원하지 않는 브랜드: " + brandStr); + default -> throw new GeneralException(ErrorStatus.UNSUPPORTED_BRAND); }; } @@ -232,7 +236,7 @@ private FragranceType convertType(String typeStr) { case "오 드 뚜왈렛" -> FragranceType.EAU_DE_TOILETTE; case "오 드 코롱" -> FragranceType.EAU_DE_COLOGNE; case "샤워 코롱" -> FragranceType.SHOWER_COLOGNE; - default -> null; + default -> throw new GeneralException(ErrorStatus.UNSUPPORTED_TYPE); }; } } From 9eb1a5be0205ce4c0d33fb4660ea42b159818461 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Sun, 6 Jul 2025 03:20:50 +0900 Subject: [PATCH 045/339] =?UTF-8?q?[Fix]=20=ED=95=9C=EA=B5=AD=EC=96=B4=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=EC=9D=84=20=EC=9C=84=ED=95=B4=20FragranceGen?= =?UTF-8?q?der=20=EC=97=90=20koName=20=ED=95=84=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/domain/enums/FragranceGender.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/FragranceGender.java b/src/main/java/PerfumeOnMe/spring/domain/enums/FragranceGender.java index 5ba77a1..3fb271c 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/FragranceGender.java +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/FragranceGender.java @@ -1,5 +1,12 @@ package PerfumeOnMe.spring.domain.enums; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor public enum FragranceGender { - MALE, FEMALE, NEUTRAL + MALE("남성용"), FEMALE("여성용"), NEUTRAL("남녀불문"); + + private final String koName; // 한국어 변환 } From fff54f52aae0904b58553b77e1cf52e0c8276f96 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Sun, 6 Jul 2025 16:59:06 +0900 Subject: [PATCH 046/339] =?UTF-8?q?[Fix]=20=ED=96=A5=EC=88=98=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20"=ED=8D=BC=EC=A7=90?= =?UTF-8?q?=20=EB=B2=94=EC=9C=84"=20=EA=B4=80=EB=A0=A8=20enum=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/domain/enums/FragranceType.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/FragranceType.java b/src/main/java/PerfumeOnMe/spring/domain/enums/FragranceType.java index 1c4d10c..6d375da 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/FragranceType.java +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/FragranceType.java @@ -3,19 +3,19 @@ import lombok.AllArgsConstructor; import lombok.Getter; - @Getter @AllArgsConstructor public enum FragranceType { - PERFUME("6~8시간", "매우 강함"), - EAU_DE_PERFUME("4~6시간", "강함"), - EAU_DE_TOILETTE( "2~4시간", "보통"), - EAU_DE_COLOGNE("1~2시간", "부드러움"), - SHOWER_COLOGNE( "0.5~1시간", "매우 부드러움"); + PERFUME("6~8시간", "매우 강함", 5), + EAU_DE_PERFUME("4~6시간", "강함", 4), + EAU_DE_TOILETTE("2~4시간", "보통", 3), + EAU_DE_COLOGNE("1~2시간", "부드러움", 2), + SHOWER_COLOGNE("0.5~1시간", "매우 부드러움", 1); - private final String lastingPower; // 지속 시간 - private final String diffusionPower; // 확산력 설명 + private final String lastingPower; // 지속 시간 + private final String diffusionPower; // 확산력 설명 + private final int diffusionRange; // 퍼짐 범위 } From 5a0d8dfe391d3a902cc6e6aa7cc9e0731cf82a00 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Sun, 6 Jul 2025 17:20:27 +0900 Subject: [PATCH 047/339] =?UTF-8?q?[Fix]=20fragrance=5Fprices=20=EB=A7=A4?= =?UTF-8?q?=ED=95=91=20=ED=85=8C=EC=9D=B4=EB=B8=94=EC=97=90=EC=84=9C=20ddl?= =?UTF-8?q?-auto=20:=20update=20=EC=8B=9C=20=EC=A4=91=EB=B3=B5=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=EA=B0=80=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EB=90=98=EC=96=B4=20existsByFragranceAndPrice()=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FragrancePriceRepository.java | 3 +++ .../service/FragranceImportService.java | 19 ++++++++++++------- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepository.java b/src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepository.java index d82deda..f7d32dd 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepository.java @@ -2,7 +2,10 @@ import org.springframework.data.jpa.repository.JpaRepository; +import PerfumeOnMe.spring.domain.Fragrance; +import PerfumeOnMe.spring.domain.Price; import PerfumeOnMe.spring.domain.mapping.FragrancePrice; public interface FragrancePriceRepository extends JpaRepository, FragrancePriceRepositoryCustom { + boolean existsByFragranceAndPrice(Fragrance fragrance, Price price); } diff --git a/src/main/java/PerfumeOnMe/spring/service/FragranceImportService.java b/src/main/java/PerfumeOnMe/spring/service/FragranceImportService.java index 442eece..e8a2cc5 100644 --- a/src/main/java/PerfumeOnMe/spring/service/FragranceImportService.java +++ b/src/main/java/PerfumeOnMe/spring/service/FragranceImportService.java @@ -95,13 +95,18 @@ private void importPriceFromRow(Row row) { .price(price) .build() )); - - fragrancePriceRepository.save( - FragrancePrice.builder() - .fragrance(fragrance) - .price(savedPrice) - .build() - ); + // 이미 연결된 fragrance + price 조합이 있는지 확인하고 없을 때만 매핑 저장 + boolean alreadyMapped = fragrancePriceRepository + .existsByFragranceAndPrice(fragrance, savedPrice); + + if (!alreadyMapped) { + fragrancePriceRepository.save( + FragrancePrice.builder() + .fragrance(fragrance) + .price(savedPrice) + .build() + ); + } }); } catch (NumberFormatException e) { throw new GeneralException(ErrorStatus.PRICE_PARSING_ERROR); // 파싱 실패 로그 From 35d2b17da9f6c458bbf2b4956df4bb1aaf42f6d7 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Sun, 6 Jul 2025 17:55:44 +0900 Subject: [PATCH 048/339] =?UTF-8?q?[Fix]=20=EC=97=91=EC=85=80=20=EC=8B=9C?= =?UTF-8?q?=ED=8A=B8=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/data/perfumeOnMe_data.xlsx | Bin 124259 -> 124531 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/src/main/resources/data/perfumeOnMe_data.xlsx b/src/main/resources/data/perfumeOnMe_data.xlsx index 2d7810b4ceff3e5fb256b559772d76a8bf97a440..888dd58f25882ef767e63b3cb7d163b1fb676209 100644 GIT binary patch literal 124531 zcmeEP2Rzm5|L>+$6on{~O2}RzI~sOGgzSB?vdKz9va$zh%bxyw$e!#w>1;ql1DVKU_R%`m ztK*;YR9#=JaY@dLOa+5lm$|94W1QAjZ6CU7tVH|y#+*BPT=^wLjX78h(K?Ro$^5sP z!ql%i;0MX=ah4Gt%ijBbY`EK1?c~tYJw?5w^eOvP_z4LQuo7O0!V@{{({Vs-GR#t< z+9$B5(%GN+byyPSp=wIyqgMPv^Y`3O4IEFo>4L4{I3-i|KqhVH%ghwahdHu0nIf;Q zRJgGm9>qTIxJLAtZH_N{KKq^qm5n7q$n+bcXC3YI#i@!K!U*VbFaLn zQ9Uf7A(}yZ!H9YK+F*gAC`cUWSl=xa72#bS;_p9_gYkH5!0S<9wg0;l zypHjqJ$Vj&Z~_}V8Mev&1;w}b^q5%Qej{Z_EEzw2YxU$Y`K!3$95_2kZk}U}$ziiN zEg4v__#%2F21f%kw5phU35X{WiMB&xli+4cGmyRq+$aj<5)%lnvazO~2|XR^-)({Y ze=MaNhMwXhCSK04-+5AS#ie?_t>@uEeh~{k!Ge?5?CuD4V`qEBoF?iip*VK%8lfYa zfPInON^MsUm&a)J$+`ZsDQ-A;oTNDxsm_5*MrM1c$c+Oo8>PIiIAq>7+cp<0AZ&HU zyvU0x`AI^wNKGH<<<_Z-83&u_t`qLVjXUOy&mHU_UnwTntG1B#M;pHsBGvhEE?d;7 zZ&Rp!M*wu>0PlTv(eakx!__8g+MQ`O)p{pq7YU`Mbl4$})gp{JPFX2y=slge7+Qk6 zYD0S^tXlHaS)2u?CV?uls9Ae?*x9MNaH|ToedWp3?`rP4ktFnfRY=}ikkUz^?byK$ zj2$+Z8B0BS3qxaFHA6#Plx2&RZZPONcd(e?rNu$Z>u0C}^f0et)I4b3A#^C#pe16b z{3Fs+J_&INFZr&jcrEWNc$*Q(J)Zh@LTYxH*!+y#6$Jt(w!?aorv*q#f)|~+Ec0oM z2LftB+*$-Qn)uPFalNOf;R5vW8SMO*Q+0w{6#VK1EK)9uW7}I9KR!6EMPA|Y#|}+I zjA&wQ`I9S5GR?Qeuch4c&wIFAn!&H*N!sZcNH`<(!LhD@!T!6`IA(hzx_4iwALdf^ z77V%TpM>U36OZw><7oQm@l-)`2kL}E2o>|8OS=y;N0^v(+&vW|SU4~yDacV*iXby` zkGmI%X|KkoKF?X&(k`=GJT~E(0^*Oe6M}||;xwYwm-hyAF}^$ixkFYk$l;byju=qS@>BL7x&u(}$O>M5BfI~sFx z|FgULqSa6gqKI;;lL6cp7jDrGGw(yIwtYxVE|T=}q8~=4mhcqcQb$O6Hhhcjd;?2d*bLo z$I-`RjW3;qLeALLK~2$_LyA+`mvkI&yfAWm&1jZYdVu#t$MN9vnFx;oK3vGEo@Rg}+pcvgm5UHW)zSC$=E!c=v23;fTQNb}FU5hcvm zRjrbsP}eUF7>$+D$Hp0aCHdB!U)NqQ)G?@H=#D!r^Zp)(k&9Es%%`8fsdhCppJjPX z%hJE%-Nsvn=x|R$uf2R-lylCMC|#?=cg8^NaGHnOyGBw(?dTZE(c(M04BDK8RRo-b zS0W;>h+Dw8*+Y~RD+>ts#H-?s8<2!W?n#dqNME9TXMy(Mv{`=ukO`#t|M!1|J`Fc381~YB|Jjg7tB1U~|mrvonnKTFdP3`-w@-dMVFj9@~`< z4{glGP?{INK7ZnVHgoS+V5pMx|BhEwt;ufSOz(Y`8CdouH_Gs-1*0j`&N5*904rxr{*@%(bP0G zpKL<16B$Fv_lvDPZTqbxy*Vhz(jgB# zuRR(Y>|%4{CJ;9kNu0hD9Y6`EYO8lVuXRKI(8!E)`(w#lR}bx0en$S#BkD9;RMP%O z3}L$ttFqiYI#lN5NU*!^Ep3ze;Z$jnQ#wL;Zv$_%3)Y!ehc6iO%{{)JJbbZVzo2F2 z^@6DKGp159dcVjiDxJ9SNv3^1ymNAzbVRB5E#WNJ&v!o^YERJ^)yZ)yqzdgbyh&G` zze@n?9C@(C!FR5&>`hMvg=rfHie^TP6#85_dk$-ttyX?EdeU1n>572}YQpNv>8C^A zYve1a6{}l4uygL|JM1pYTlQMD!!CNT%=De3EZxEt{}d>8nQsBce$L_;vcCAeSXbGm zs#$NuQQRFo_11{pai~pmY8R~}tZc?m$mgv^J+<@ zKSZ3yON+c$`JBQqg2XVf{uR?sWgD*cGA5DzSC=v(frlSlm1G z7w%pg3KWQkGS7e zyXQc*hUOSvTGru(5u&T@C13}I-G`!Xpl&BSguBW(3zWvL-9w=LE4wqV!F`I429 z;Yj<{_f^Wsh4Oj()jH&Ik23O|>igFgB`X~y$Q6;5zR9Xq?2_fybo&K{15@$HwZV>( zmEoab=s@xM;csRTf`soe1=(aZz-3A z#}3)kXSc9c5Q&qqAneY&l16%&CBsxr0XMQ3$5+W+NNi=n-*WVjI7`NoaD_6QxC%zz zex?4V+CYVgm|h7^R&PPQuG(;gh}d}eks2Xxn@bVP6w-~OhYqr*Qk;KNSiBTWQsc^P ze`#n{-J5y&=t1^u!#mY^w#WyC@B0L=Obk*pExpu!^WIKKbS3`Az5!#q61SDNKBr|6 z2Zf9=afO(Qhg}w6g7k9)^RmqMjx>0CF4AfCdc9At=sV1)_Acazk>v$<*+R0*T)l@# z$tPV8`C>m6J?-jQQ60>Ch*W#c{|I~2aK(GxD01TnUR=`QMGx@Cr0aS3b5?DuZj{IlKYIkVblD~R{oc2785GN072V#e@xG4GfY3M`)h#m<4ZMXX zhyUWi5!XWU%lI#xkH|^fP}AT;-1pv{O#$9;0B;C@H^T1(Kj2KSsJ7yvfJWYuVsDCc z^`2R|>>f5eUvbSElf8-HKJ)W?G3WZ9WxKVKDJUoUP_E6L^clg2ijcE&1DeM!bjs93 zX78%>NmS3*cvPJ!EAzBoBO}Gt`&s76%lLHxN7#ut)ig53!5hXWF5{PZ9}%j>W$bUH zW;Bjya&<-G1+oO+vBIbCy?=rlJrChSh_&r!bc{B;!jG0kb71eh+a7f(- zFSOlIui?{L2o~0NnL+j`R#2is|A~GYu7RH<-EqT>m&@ego-c@I7~) zQ6B7*J#We0zRunk^jsig;6Rji(K#|`qaA*?qwK zDpY--g{Gl0B_trFBs77eIoT}#6chbl?M;QrcCZ0H$Zd9&_BTt<9cpB zB8rO4wACt_w8i5DI*vuUVia92nA96r8%R#&f=TW6QCEs3NbU}KYleAJPA`%pL4un_wunpH_9SA#yA27=zEMxKc%vu7TqIKw-2ccr>hAqGT!IXm@Cnw^U z*QF5TP3U93HrORPS$gJ0H%BQ%_4-k^)`G`4Ng{c|NhTBRipSN`PMIZ2HNAAEYN?ZM zY0&VX6S!#AVs=f4V?Vu{wbN_u%qJa!V%a*?88E$SKYT3jmK#3YQu16H!gVg9kUzXB zX-_i6U8)e}zLKw@ZibvoF}sMNNszZ+T5dii4m=I~@U*Q0CrLwbe)!%s?Vi+Vu8Zw) zm8P{Ea{JwaUOiFXo19`DFqnI>HY?3dI@7aEw+0X%HcrrmYV;Tc`Ir@G3b!LXDdlpl zp&CNRgM8Y|HH7h7Tqzk5hbdDDBAI8;jj80Q-3-j{W2*MFo-Q@@lPQYke!inKo^`}{C!*ajGnIv(Z3k_x)8 zPj}7w8mwHcVt*UOHPW&?n{KL}8l)_e;A7h30QaA0k2>Bj97xzhIr2u7_VUQ!nm_n* z`&*v`Zwf_%OF8!pzJ3@Wq2#M)yg{^Qa1sQGi(| zlftg^-6gkgHz_2Ng63-d!j?^-#|`NVO5HMxQ)|S$3m^KNk`|iRaKf|X?NcI85YDHdjx~)IZkdf+ z61LF2Yav9yKILUcOT*GC)l$Q*?5?t;lEUpvcJ-mDVv3I`o)RY|HP>E!g_Ph-evIOn z!(izAVCaH;wHRRNCwEg)gP}X&J%S-`HEPcqwV2@^^_l>qj^MkS6dMY@z`y8E#|Pvi z-E~fHCEzGLE?afybvW|7Zw4@$~M7T zw$q9%K92_0+?KlpEo?qg@wxjtJ2+EKSm5)qfFW!c=);z^a&{>%--%rJSc%igu^I`& z9QqatXZ$FL-D5?`za+%eKC!T>e|7{lbr?R40+QznWhCzt>dx3U_$iL4?A3ZC_K=n& zP@n!}`$A2c>4K-vl|5Wb6a_7+G$I_aG$Z$0imTa4ItF4zqL&`d?my$x)2f*OR~n@( zk~kxtZz>_Z#v|8a=Az`2M&H&|ccahdj&SX{*}f7P$x1kJ6MdDDDQ%@|niEPGdRBz; z;>Tj`EWwRoHZ?9ugCbDy*DruRxMiA55>CL*5NDzy3R(1_q*Z2$8?0N`>k_*==X_3d zt;=0v0R0G1ZjuR9Bhbv_X=K-g@+Z!xJ~SQ+#r8H$L`L{rDHzD5xX@d?Bt;gEHA(M! zH+D?TKeqgxeDNE{W)uxe_+}a+k19>piHX<=6vW4v@Mc-(8}JO2#@J62&G|NBQsUM4 zpk#04x-i{)yLRFx34c@Og7`5{pw*R=(oJ)V7lG1Qnxq_oaq>8`d)0`RXr2F45S=yp38B4&;{>V#`HnT3jO=S&|I@jAWiujRMf(X9V-=KF9vv&2< zr}zGF^NY1PffpeqZ|_jZTQdtl!Do{?>)+{Y(B{f-`Qy!Xjyo$6s38`;ob%~zl1B$( zKk+OlP!Sjo+r%sRU`g-CimcB2nZk)l%Dc66Cz8Ai8k6BF<#7Xq6T{He7FY&mj zLN=3Je1)yn*jiIKgFYo)Ibl?*?qha}tfZShmJ8xxBIHM*7@Z$G@GkSobx~Jk zfDVidgMgjZ1a=yW5@Ct}M%z|Jm`B!Av}RA8E9Ih!m8NjxD!UYRy6LXPV@D+Cs?$ZK zA@Qq)n9rt%eacNFBVcW&w9)yn_>IT2am_chNn1Rvda_ZTwM;h$Om5(#c&B6 zEaDfo7S@Hn!G<%C)meivPJ%HO0>d?pGF-I4aK!+_Wdkc@A{$-{ORR#-her7FN3RH3 z2Pd^$v8Ew-FbE06N`|M75?n)xOu^8#T8*egKkG0b^UO8;#X%~>`(c80de_$JsR;+n zhfvD(57q4xyxOBi@YR4sJonhc_4}oT z;<2amCC+rIX#uTdo7tWX@IXhHaB#Q$Fj{v<&wyWSYogzZvA-$2Sq`<19(;bZ`{TC|ASan-gSkkA3F1UpiRL zYgY^)$KXG$;kAnzrPVi(1JHwNaE&#@6nr08MjI-pa^3YiF~${fKw;Ob{N_f3T9k;d zN?D&iaus*1&4{K;sjRT(n*F^2fA!9}>0HscHxk(Kl6y0i6rGo>%M$GpZtqb3xXkrpw;Fz@}lu_A_TB`s!>>8nZN$8JpVy_~}VH}yHQeE6V^ zfWG#45KsxlF`HC_VCNjuRj=H@9elfe&!nr6C)FNaUGx^=nQhxGD^n4;f0jN}YBi)D z5QcORLj=us&$M%qoI*(A^2CKV#)+y}swR0v0_AS5m|}e0<+Z*eP(2LZnFq)vb4oAP zvIYruqT^C_sqADqI>vCMOO;5}MxaXtM^pJ-&E$fmW2cxgm*q8#%b5qX9$eFWU|Z_K zzlhH4lr-H@{7MR_fw470k$i_CN%A&@2TE}Si-UHQnm3i-odkw#7#K2r2311!B;{H; z6Phj+HE^BMBod9)a}@Gp<;Jpnztj*wD#fwC(htj#F|J%C__j^zGOY>F=pvMqlKzz9 z9Q&Q4$Aw*@O^>9kGpb1N{nZd!f=8iW#MDrvm57OmrmMld`BCaN};$ zS~tpLA}HnsY=x2qRrtwR-Yct@{S?QUu4#sI6YRe*@{I06n^|iFrVPKC+;yQg{hrqI zj+FD~$i_?=$VwDYUvj5p$Be{~&cup%#?+=vC8uhsuapwpQi=yPWpJV-7F8AA1Ek^e zmwuw$jT}crgKuK?AGiSVpW~L2hC}j@{27;*4o1=+6VWRSkR_Y^G%~-!)*6(wTP&H7 zh{VFO7ZWAOCSOt5K$lWI50l($RAq`Y2@S~30T?q(%!*KlbzOfpLf-@~ zL)cmkMQB_@Zo?P#Fq}Ii*B(&HJpmf~HSp(nQ5v)xXwX|ggSu2YP^PLe#Yu}oVGmsW z@Dsq|+2lEP`Xw4Nxwg1IUa?yZe=SNPXAvE97bapTTjg`uh&DqQ&Jr&bfgW-tuZj{v zp{?qzoYr!sgg)amMoVM7)Vac*E;Hog7f@~Wf?Qzb5y0qk#*X&_qn}GE?Dl>S7p^=D zFH@Wupy^rT*Ure?CSKs3)69d*(keVdRGA4?geGZZq!UZfBqwSl=+`EUgnLKs2;1e=axTOFxcbn7C#vkV=3v^x%yeP~O$!T#LdMpr%lhmAJw2*4 z1qI+IOx&L?ESHt9eG}6mNiHQ{TmPc3roaFiGhdUy>p10{!8IwFLNz&Vz2``s;Uq&q zimR@xG*0@mBg|;tbSwtDnYN^g(l!196)+W3<8FH{^7WfZ9U7d&LMPMWsLll@cz3au zF#duw9YBkWR{-AcheZo}+S_7qb(e$U3J&RenI zI$zV8GkIev8)0!o1iuk1rBz3mq*Z=)i%WIE3m+s$+#|)jXRG=e=k&Bn1g_w{QDnov zI*mn6rnS`OeE4|9i)k!%IXU1yC7;mLJp5>UzIy6ycGxYI+FGgwPZ=3L@K+FgjxM1R zvky=2tiyCZ#{R&1*n9h$_~fN?@4%E~o=zi^sVSHcMBHX;j;fVnNqEFNkzGxHy@N9~ zh{pA9;SAqlEJGr(BeOwPM1N3r>73Hk-xaGD1C-oZ?@SMJ$@a_VYvFqZ<-mu!q^43t zMUm-qL|jo?#m{49i=T^L%YObscbF_ati;#yNF8fPkCA`Hn1~tDAWGFge)J^O?KG76 zoq~k|Y>w09B7Lrq=G|c_^HRsEhxIUujMuU`&nv(M6lsWH=VjakZG_OVk++evc_I|h z6kX3r9YvFn3*HiuGemP)ujES3y>s?EU25&T1ew?46}b}q-Y8X>=|y@nS67oypaj`i zNETJAgO>D!QGOR^k*y+-6uI>;`e_4bqfhSAaflIWdT~GHvYava>rP1wVmksAK`noIRx?5;)V)Y^Srui{uXm&yWBOGMLo%=hvn7GT!G#h zG~j4{QpQ4o4gGaj?LPh!V3d(DWv^#%>~>yT&K~gC!eGNdV6M&H0Ck4z zGbsoBxvMgdmCJHC*JQxVfq>8jx4<=_OYB-+dr03}rKks8TqKoZl8y_?k)^|{qtic{ zM5iy0T@0{Wd59xrs{^_ekPzn)6_kW9tY%jDoT+B8*T8V;I6x+qAFUxw9Vj~D>_gcj z!4yX%3^l#r>X&oLQ*`7A`TD19en5YlzpKU*$dM8L1NYA_~=&RlA0$tSUD5Z6<4xn>Qo zR=i|+jD%-JWM8^j-AJk#&-s#9D4>W16kRf`wXzvN{{jR4nC|M*=E^L)40V8{>QG2( z@_^K|_Mh;L$9L`nT&zP>`_aVWO2VrwHHBMVg(a@-?BGFY-#vlIhj=ONdoekHBaPS} z`D`4sC~fKpeOFr@)@OL1Z-Q#cTXie*Z685P@VTMJE&DvMHYXn$xoxyAwk==inJhOLd6~Glr!Ga(o4)|M&{Ok!)yae?b(Z4QPim# z*%LJsfKOG*{+LdS5$-MJ4I85`Nq>6+3a?h4iLNDqsS3+X;8P-e#)EvQ#{oF^+XL0O zdOXY$)2x|*;kyZ?3*kW;wV6A79py>SCcY7pLjc{Tqr zMH4E((ANM%cWT$}Q~+9Gxjrw47qza7D2IS0PffV}4R8p2fkPl>5bVQ}r(T<8!LZ8M zal;$!8KJfPG4;+_&olahh5gURe0>^$09l;PS8Hv;!sAU8Xsxb(FfjDl;q%ceYjjd=vj%ip%4hK35Q`?)XOp2&NH0sgv=*9Ja`ym0S`dE}(lU{Q zu8&RLsl0AUi4joo@axsk3u;-^hMKbaQnEW)um25i&=A&U4sPibg}&)Vg|H`8u%4A5 zE<_hb1Se8WDpic5)=Q+S&1?1%_lKeJmP$@C?;*S%99Oy0aT~g3{PI*ucT)iY;ttAA zj%i8Nt}0Ua?{g;5mr7YU0ZS|MYnKjc$;Y_LOiO_{7vKrjaezv6V4$tyd;EagVZoDD_Bu6f=LK4mG16N!UE2I91PBE^_5cJkBe+~!A}SnupN|Dw zDlAE);!1JgHTctl>t1))KEp!+6+TGkZ7 z0Ez39&vWC(%bU`0l&FHcr=(*NgH^0gF4VyUmeRjaw=uaMJ>>d2ht8lN)+BkRDA04g zi-iEYUTJ+mnXFyrSLw!T$9k-8H24U(1m(DK!Mmq4XGLREB3%92z(DD$*(1}dW<^7; zXB4wLO#|9Vx_)bw3r=c$y`_bbOA%TIobR>$?JA%?` zK*vPxk;56Zx}K00;rco|H(O13K3Eje7Y5{+DQ+2f9g#JD6F@3#0TO7^03Ql=rUz(` zzUJ{gfh0V5HNqk|$@rOFZkU=?uMv$L4q(l?0^y{ky-jEhAB50qT?psO2S2QyO9!|U zoK0JJ>;supKxS6-(Pa`uVBNmZv7+jXL!x?|EDW>|R)$oF*0|9`DLr!Gx-zBTJPuP^ zQ_(rirMO)f=rw^@DJ4c8Dw#HEWU@8ILodL%q^Q9PE}Py2;i;zLbEdSqIOrq8>eLu-z4E(IX6!VLjTa^)k%?qMzjeF#?wvxU8}qMvB2z$*hiTxNtC&4+4cf z4Nm0Pmpy9|t@7|5Ln+fzf;d@0fC|~ZP*R#EG>^wX$DDp~C}71n{N9q(xJcQFeYVa{ z1G?q9ZBO;)JB#=xJ9m;)<*aJd&qca-qWdv+IWTG6kf?yITI(&S$yFOBwbFRBCJ)dY zwGML{p{1^_tY+}bo@bKQj>*vF|SGS*{u_(q-W9s0K*WJH5U z1pIyuOhhRE0nq?t{9XSWPtS6kFg$TI<7T15v59;KNE;YaXO=?^VJ{1VT=_CO`Sn+5 z>T}&0=vthTTt#p@EB%XE#1OXOt2~3_sx<5}{?2xcu@Vu^u1U;Q5!{yz*#mKx$u8j3 zvBvf|_|uGw%pzW;{=s_@A9{JMBZJBIU~NBpkphJ)x2-#)E$b`r3uxZ3Jtn49m*1&> zEUo?g(JX<7EDWi4Ss7>@!9s#-v5=iftI5~kViqlBgwkq%6T5)5jq*zmXTNK+@@Jf; zdzGQ1^%?Yst}-;0nJUccNAq-y164pXzILhrAM5P+bQpYz64UG@kWVl{Zuc3R_(?2Y z+e7+DZ>r>wA$p@q$l4-9`p!P;4DgEq7h{(Xm~P3+6q?$gwo6B|%Fd|Gc|5Y|J#Hve zz~`-m4`*9s@KeT=N&(YIeF?8Vi{V*PR&%lBwGTAw${a2$M@aedYDRk&zX=?pflcBu z4#~YOr}tocJbI=c0#fvG);;&NX0Li`?FMA{YlHj+4`of_Pen2WHifFAwHgwzfKglN ztMr$#t9xY~98$5Fz`bCF50y0=dP(4ZCP@2aeW^PSoWNZ;#mA|EFKE~ST@{2_ZKJWu z)+rhuK~aoh@L+p5APbgpM7MJM#^nQ|GD|rQBlGTrL3Ym*9BPDl&obm?9w!R2=?xuXPZ>@ROs1C$jMy^`eFTY-%v;&^TG8zfp(J^~2 zOTseQ`ZGzfW+_0&w>)MinBi(xs0UE~Lbw|W%Ad>J6L@m&d22I;I-#M;;L#)f_oZ|O zk5;B|l>>4|vZnW|%S5Qw*D~LSIKHVK79-&_zp2vB@P;jWdcSDSOVXs78#0ut7osng zdYs&!<9>M#kJ(0qy6F}Tb<=wRwPK#OxO*M6BQ{Z#!Pa4Pm}ZW!YWfa29WTZU^;wTq z6s@3B=<*Q|l@FJyDADOIbm(C4ri)y6^u^ORGeg-9u9(B_+}%DnZw8O<*SygpD$V9h zYCGMzyH7If@gb4Nx~^@CnAC|>K8<@qf`N6BOJHV32~u`l=EeP)&GA}e%Y_ibF>B5-j&S9!rR@x)o_!r-a zLPu#}6k)0ib^MmEL={OB@yZ19^WnEVtrMh9BiOGcSDm5p zC$ZPTi>cAP=gZ$6kJpICYC`eS3%WWS#_BSIfS7SX&P1e185~n?%1B2*y$1}+kT59a zy*`?+2}~FKhWQUv|vUCPdL-m2QGG?}uaj8mx z(=m}`W)L+fo1o8JF1vBT{yvSU;~&a54x1211jqkHpH9Ap-wrLc@vP^CWaa~+UtaDb z2bMD&M&5~{wT`Y|mB+PJ*>1LkKF&Br={&t$hL_yPuNX&wp~cd&zin1V-+Fz1dz3Zg z&NaWQFC>h3t}efEz?Wk*PI~CO!w52O9&?e_J{KabeX!L(bC9hqE>l-UCj2#9_9)=r zaMk)GW_LL=XU62l)8j_?IB(?k`q@WQM#2GeI2qTxEWdJzG(cKAAB|e>W#H&&J(%E% zY=#5qvASS}>mf;MDF;F|(R{^+#CKBz-F`yEvdt1ru51jQT*R{Vsm4A-qM)!CMKoWs zLPM2lK4Tq~Oj%EN$c2FyPb-2tHn33%{9?3oPL_E|0yFR2MkGfTinikMYPQj8*%Fw&(&6*5dntVT-eL+OEYZ*Kj?zf4-Z1ubBe zCy9dWauXdstm|+?qQl4u2wLkTt7b~1Q#<|XY8UMwACtL=L=F?!qGjm-PHV|owSDFQ zQ{%I`Xb+(sXae?*=Edt^VDFr6rx$6|XEnQMFDT!n)@XZb`Vi(YVi8IhgyqA^Kzv$X zg+mbj;Y~mT7kTA?D377i(N^cmbwyaI7NvaO@7S8PT{#ElEQK1Kr9yzySx)AUqYMiN zqvQmm%#;|HTVG7_Iv=3#Bg28TpeV?VjG;pvV-JY#5h4nDS)i+8Of`?1%;rxtTlrd0 z-$1b}g9@75eLu~U4F&GAEcPmAQP#i z!NhDw2of2dh`-eaCNCZJ?8B`;5Z^f73MZ4wCAo7g>B;t&Z>BWGSd-RI?q`;k zX02*Zj#4q#etAEx=mEe-<-$obx1vUZ1J53Pm(3G%_$kP4HC`B#W*D-1+sf?0)~s|1 zt@W$dZuRzL9ToVetNLa6(tds!GBPUX){5qN_cn-5UviXAg!5S*=A21sKUuN^p##kH zXdbAwtBdwTse-exNk9qmb^uU%T&FruZ@s#jk>k{UtfBb&^QYs+_YIfS0xwjK8&?M@ zVjaz@JE?BYu2E0t;@&xUkX-$hu^^rTmQnrXM!0oOQZYM>#JEN_Td`dq;+zx~@e(@^ z(8LZ!J4=stpc&BQdhtgH@WEUFmLRC8jBw#O-)f1bfBh`O*Dg647j!5BqcTPD`BT{X z<(=We=CVu7?9`~onU{Vo&2<&*B%j_NIAKNhEZ5FuagD5vgjrftJ`ZLN8E9#b3m35t#G)fs zbth-H z{vP;o7@Cei^OdNgyXF(Hh@Pk-hdC_xZWw(wdBk<%6vL@PUDZKqeM^II=(}#%yKYX& zD5v2{r(x)~A417oY_xwpCUyI*+UWL8kaR0N%hKydyD-xYCw!d;0T1u*-R1*@uiQ*J z1c~1y)sta?IBpBWxov46Z#fZX-dtufP-fEl&BsivbVMlrUypf~sY0q<_HM(8wbE7w zySvs$hgq*~Z9v(D>0qYsnu+DaL>Dfv`K zu4S*SSjuo-8|=ea`fpFnGJJP*U}#og5Pe(#ZLdgRM{J&G8GQ%oL# zea+ad3U+r`$#m>VtqF2%*R8(>zRnAOPQqW$+q%k3(n}riBZvr4t2UnvpLs4}_Qd)` z9&eL;3Dz(?-tzo-$E@TN622P_Iq3RmaC{iCj&(Cu6fqyC!yE=YuXU+o#s=%b9C|mW z4ncGAix5jY>JHO^oqdm8h~o0akxI0>{=+hX7~h+ZPqXDK^ToW}$kRFq7%_gj`=&(Q z^J*X+Kk(p!_;2ERTQRN@e_iw`t~Oh$rd0I9e{twWZvFB(Me6iMa)s%x!@0%{CnDnW z#Rv1+qP{5ypNYs<#9~Dz?P$f!wfRJ@T*7OAan&dG`s$MiSnb9;qn5m{mc`LBVw4BW zvEz@;F_`SXndUFJ1f@p4@~~>5+O(?6>ubNi-zsVOTEvY5RNv#?jm#`AKe+M!C=oRr z4B_JvH;b$_N#^^u68ua*exb&)hXLpd+YMv&Dp7c0FDl$)!}TO ziy}VHdDna=;xNo4ewMpwI7->Y1VOxp_@J7>++KLIc&^QM$tofn{1TBL`z5P{a-)$2 zbLa||21r)a9029Kn5w{he>ibM%F2KxVNgeJgChCrN`H7W0`@PT;LYRuA<+c#v@M~`tzh7-aL=-g;Fk3#Tq-Z9^W*afH78NM=R z9nr*x(%hUYU6?w`H`g_vbM=f+((?Nmu79A5msv8e0ckk!Rkczf3yYB6tr-yA_|$25&afPq&(E!8?j_{d~NoJFCa72}2jf z2Mr&uJhP}We%_|P<6iE#^Q~P>Gtco& zpT%olsoU^|S58nX(}(vKyA6gq6ig~?#(CV>iLU*5ug|{!;mbC#XP@pdY|dOV6hb_*)^~T)K^Vp1!eJVo`8>||MSZNH$L6e#vpmoUL0|& zgx9p|^>KZWnoU=FsbmNb6|*jYOtK=z$`-N>{@yB;8kMFFi0dDq7G`?12b&WXSH8JpTW{z&aj#vhOWx5YyK>HPdKojh}SZ zCU*M{K|te|S^xj4`2RP7v_7N#(m*rTdpN8|UGMYz@G)8u*HPU?t{WlSHBF&$O)rAdzFj$)we{`^w;If<`(HIRTZ=}>%*1boH56MlP-o2}7aKtsK|Am}v5;X8z42Q)lcB>*!#!VP znr#p2_XycUZuX$Ww~B?EegYP5joh|Rzd?9Vp9R^p84LR&W}!u$u-wo&gVtU{xQp2G z6G$X0Vg_p42}=*1L*z9KdG)FEzSiSqom}gNDeJCB+gv%3O+Nt(kMM4D&F{fApLtMQ z=9-`3L5*yCP`?9S1G;AH1=jrXg3@`E=tbBFy!WhP&(GOGP2o)690vf;guQJZ5L{5P~)>8 z&ngMExsQ6TXWCU$VXIc7FvdS!~YbrS=a2_HOZie!ky4I}k zVhywM%_zF9`Xd#8|Dhmq@j~FXi~dtw^#3el(+-J)qY{5Y5Lp~~w4lj!xan_8{>JS& zyP=wmChI18bKYrodbORe!S@Kn_Ppv zjcfkF-qT;0=A7RG*L-#``8d|pK#e1I|9KDRIie)l?(l54r1HNXW`*3S8xsgV4!cx zzTlflg~+wo?>Ye6Z3H&DnZL4ciSkd{1e5;bc=($e!dth)!@o&k`e)(cA9AC<28g+T zLZELcbfYYNRx#?8vt5|J(O>v={Si`!kYPWkFx~!VU>oFi0oU(Qz}2@o>%C=I^HX+C zZTsGvt4B5`EH}FgzpyYp2)P(}fwLRQ1Dm+jqz~6%1wO*xdEYfQt;L4$jXea=EY^lm zx~6B*M_`-*Y|~IKGsE0_PEj@IJ2Oo&<>kGuW_bVcxtpU)&qL> zZC7ypTPnD`fGtb^)P1XtqAOYKd&$npkKMOy`wEvqSDdtu-M9Wh^1-(2>)p4WZFj`^ zU2;}4Ap0=(X&3Nx&D_n_?`-K@D)`uOppR{G4Q!eD)N#P%A7md`zg~+o^`2t8s^Y)e zS$1pHf}pB`17shTK2=rNWER0;-|6+Yw%$hx6-b^3Tk}{E!=JY15&rnryxcF_(XMaJ z<66D6Q3G%HQTmrQqx;ZD>1P-y$j?wU7yr*N4R6O6WnZ8h-p8(EpUDGg&OAlp`7x&T zkAU;93aiV^4F4o#WA%fKvw*F=ob&Z=v~Ka*8zWwy)u;c`?c-wSKx=V)*gtA5zBL2z zTV%+QwxC{g_EWFFMUC&bh+OUY*xzIl^fv+i)`u36pufpK2qPPQqivhzb{Ki{Cf9#y zo83Rd$hM#QoA7jgfB(4sr@D&ep&nEb*N6S%M#D(=AKyIH^^MI_5o^)gdCtx0`0x4$ zzwUg75|HOq{?sl#P(0^)==V0=^?hoWUTigA1zlD6*e<=~AGAyVrh&5mYp>L2n_y5o zQ@0ymZ%X3*y3JE+Yb&3^$R%B{8a;U3-*hov?IE8LTh4*>u^~P&4OaK!%!e@Y-RcPlzci(({^S8Rl@c0G|hj~!&v|8Mq>E{`7#M_?XR!J;tbo}K_l-zdisFp%f{NxM@jxm0WY|1|Tx>|~xYg;%t@vExlXEcXga@j^Tzd@TnQ0(`UP|ZIn+xjM| z*)H4qO;F8e?e%}8i4w_II&X>p3@A+TZ!Jb|U;)MG6It8E=-}x0;hd1Iv}?m20%sqp z&rEFG`AhIKn1*1x`sTliX}7ke5A+k=!nwX*1vUR9c>JwC>D!$lKResx-`bs`WT7dV zc*34U-r~tHt3Ffc_sc``A$<{D8pVIs6n_fuIdIxffkc(4#acJMiFx9;D~)V=tJiYN z_2q}aMsQTPYI6{jUU_933wQq(SlE7Z|D>YFf7j9kG!(^ND*^4+=0Wp-jpspwgA*4U zLDxC$!0)uXANJ52zm-!DHrb&N%;tTFO2rG?apB*DYc_jOKgKm*!8N=;UW)$Bj-vG2 zZ6PGjgt5Dn0x9+*YwoLwuA9=u9y83u`DL)1ZSdtio5sO?gY zRxVkqFAJA9wn_gPZ6V!CHa4``REfU5UFwVI=dTR$&HwYWrjV#o3SQR9wNJptl@qyr zY7nrvziuHLRzh?=gS>jQ8MPh10}y`7Zker}8pQjRme;70JpQ+bh!-zxgW=x;)_fKi z-ZHHD2RrP(18d^Gli-b^{Z(x_egm&&Yk`+7w(?J2Z2dU~%KjfTMVkLcQ>0?cN83!Z zg$hC1|MGc#TRP==t+cSb(@w8K!5^y8Ta&{UggxCa=9%|$0gHcOb>y5_&? zH*LAg!0j#Qny=L1*c1QnBRf2+{u|NWrmD#8jSQbp%`e@^!1>S5P5fFRPKn2l_xA(m zyVhH=eVH7OT)X_uQfgF$cH%E3`F|H`ZcY6Es~t#XW=bM|&i0qSpOEGhxBpDNe5^-b zPws8U12>D*uXMag{`u*5Uk&Y4{WumbHjJnXvtW+b+?)vphlp<<2fev8>0esD6|$8> z#J>uL|3Mkfx54n9Z7}>>fMMm$)mz-1-#No$z2r}CZQ*O1rrdtUL7SMH0}>mGPJmv5 z8*lBz++Wq)_cy>NTg$}dgz$#{98}Z#GwhS==U&1m@Vo6meLgk6&VgF3S}9qZw_lr0 zw=Y>5E%eoaPE%0b zRy=7zylxD>CwJ|fBpi0>QyF=mzi8sU7CpYiYl?nsGE8-5^itBRQyJm!w6rWDhEpuA zWIoP_z{-#5ZF7^;XCZcPOs8oN*D7Mo&wH+zg5APA@~X>#bv({-sk^02So_6dQtM=5 z)2P2H;^u922Kkg_vt^N1WZ&cgfm!+ZK5@3L)y7vLwP_vBVlchVirY~;h8ntFy1~xJPTz!@tm8Jk!=J+S`L^d#&BeZ z0)4?uAg@|l<8o(h-q45D<_OBvmAoZfTM3?h-cL z9UIu(wbsT*Pu$-<=ehUuzW(Bu9dh0+!SZ^S>LH{? z=gLgCizti>d84;S+;;()A~Vi2mk*fti{_uig1%cx9fKOv2#tu40}i$Kk9Y4b0J|qMFb1 z$?<0Xf$3Gs8|JGx(F6lGht;b2z8Q&RS0-BzXAS1N25UKaNs6gm{s?38mAzK5vHwyh z_T|zYLVk@!N(?slhD-hWY&OMnOj0Ho2#cR9nJRA{ziJE7{f>cPXieAiHG1BJfsuyE zmZ8`#MU?lFis&OTQNc?*qK~n27QSgWeQ*bKv4E{hTMb!p7qAq`zg@o>8%02r{NS^$ zsn4CS_KG24sk2z%*jC_bL%O`RfK+U>{qr}}VHk5)IixD}TEz039`X!Sq~yd~+-Z40 zK!W+DTQMV-j4mkHXNHkl>m4cPVmBV$%ZrU--jQJ}0ys}T>%Y4gE!ij*4h#ixdVnED z^s7d(Pry(ZhciY09cFYY=9ec@Jv+Bl?ivNyn7X}Xk^C&(>tnGPBl#nFQLHR7j9a)X z+UWg;s#_}c-TG+i@RY_^Roqu!HTJ;o>jXY{M?rY_M8nz0F*K!7vI^wOwv}Q&KmiSJ zgo((726-&T7Bw0#+J>O8vRR{+DGv7cMdj;QeGP@ZicZ77wq}62#ai` zyndtV<}oAZIi)dC0L_3};)0^Q2KGUsi0q`OF;d4;EXd(ON97!}a>@Ut@^0K~yGZaz zYR**P_#NONKxfy?y_7cwiEejgry#ts`!l zKr6oTswti`!ixV=*j@Bt+k5uDW2S~ZWMZzd&uY8N6Q35;J2WThDZFpCyCLQo>sZ?@ z7&ylNQPhyWRnhnOi?wJJ+2Z(JtN5lQWfW!Ilo)Ox5cn2YG5+FO%>=b#=nfmcvyiTP zSQ|v&9WU8(=@Gt!x0MzpWAA%R9Ox$DOeFy_Xvf_rFF3Q-9Ab-IVk(UD|=+ zhl8u~k&;nQ)}=dJX;sN;*$QQZR8q)7PPGud1qDUH1uI`7gpyG{S$$X1C#SBHOLunC zz_Y?ccd@nL6$QJDXN!kE+y=NuT%&^VKy%o)R_%8VZLa7g@Oq}W^GV(pJYbPz8+#&S-`IFmRrXg=dcL`*g!@Q(h6P#xh zm?P#c9V)Gt6TuB8De0P$0FOL<@K#h`F?2!x)uL(69(Z6MAflke?B-|po9ZZoUp?d^ z*nLo~|6#!RDxidcfEg8Rsgecml22BI5`kdLm<7-xuj@V_gG1|m)D9g1yLoa*Yj5O@ zBA+#N6ERrJ!&n-~oK>#~00Tk>i3_2VMqKf}yWXqxbQKVCHa+Fsb(?E( z?iKHrF*R^NWf#iGBTY$eokc>44yX4?Q4-O>=m9H{V_AY)q8RuG!$H@Bf%YC5&joww zCVq0_eKo-%D-B6>zqNvxiW~$3=;D`{XTfORLZuBLR5$C=E#+N4;w`W896kx~j{mIkgqZ}9#v8`~rX??oxe|j)l(pW-?JlNMcJ#5^D z!N{vmHbO;{PT!yAcT9R6tcsoNBPZ1}l$Lr$9d$lmpR2|sSay7DZ5_SqNnLIsY zIoOPDJYJno@;W+>haDb&Fdp9>#qFel3C*4&4&iB(r^~?H@hZz!tC~SZpiqP9%#+E^ zCxX$jkCz&aTf2{^wAxRPKU8~?iiG%y#`+~;Qa09h)oao| zTU7K?FnMf4BBoD6qVzhcU}Z*L5~XhgFKk7p!$(ZNGkHJQQ#*TIpa7)4Ghh2w2NS@> z2o?^V;B#_S(FeCD#I^nuBI4c?a+@5Q-#-*K-rM%+8cH!;apf>saowrP>ojmxfL3b7 z{dLe0*0+6bXkiw(kjKfAEqr~xyKhK0D0z8vGkbs{$#-sT_1fm*wh&4DnC78S`6!Xm zD%=xYgXC?;$B*V4uG~Baijt>c#FL>;LV^y7(}$nxtw~!cgkl_PIk1h#dBV)AZ4@gk z@40uq-ZN>O^x*#%xIIOveq5}$Dpos^Ozyiqm0r)CujREl!CNRiZ?=0QjXVINRcI9- zSi(nrFuq9(k|p^kr6KI|haa`tt=?&G&CZ{;^ckDqDT$SGVwLyrBnZmo&q$-kRngrn z{2E#s1-(;oaJ z;#-?XcV+~vsxq3nmxM+(y|oW*)izEhB)e!s1EO;CC#LSvxmN*f2)6OL0gH33Sq;j zR8@dfRbE2vY^`p@V&b})m1yPqz98~k&e6s7xx{soCOO>iX(hp)fM({U2|6>iVodY3 z`(K2~&Y* z3)O0sE4N4R>I-)d#@N9}fX^HCBh&aj7+FFpuZx0$T>Ta5ZJ( zrJBZVbNoBh1tex`8q&40lXuHLE+tlzC8jvGUMrCgX4=n6#`|nN-hPR5JJb+P#-|W` zYrkE80SNXshqOK|Cx;{l$6+=7`iGt8)3PmhSaPdZh_=6#tz_ourRIII9_JYdGfz@# z$!P5$VKO3NLJW)xO}F-U=1ni<7c>OEZwUO7aAmIc_M7t75Gyk_w(@!s#t?NP#ieN64< zY1@Z3Hm6CrNm=d|x&-VXwajF^{c%`EQpAf!6249nCISkk5z-(J{=2Tn4_B9F)O_lH z_(%?x#ZiXX?QYL_Y93o=b?Z%ae?liPEnHYrmZ@2DmXWr3IDGzdLLeNp-#7QgU zr*<~JI_F)buHT?|)!5p~_~W6?HTT1flbAe^wEW4DPhTqAOVr3CJ8X*va+eR zU}vj;a>V}DKP*E2Xf9}Y6V>phG$9K>oMu4)0%z648)U_$Cd=Xk-vH1MVT?`8j7@AW zNZADJZ>)~fY*Xenuj4n1V$QMD5+O4L(iygeqRbT)L;N9?(5g>fbf5h*Uwst`A2NVX6OM|NVb)xZnDUXwxDj@-{36xi%mw-wwszgrYE z1c8pX@~Uj=Uq3A@=Xp+6B)dH#;2{!^EL=q|@Yo?^n@+-XGA{&$45pBAoE+9XLJF2^ zB-eu`f2(04UgYt2b+ac7smpi%y9~lC#Ikwu=q&@hS3xh)5v5VP!4p3eT|A3D%mjF_>Ng& zcRgzgzF7m8E}`e3#8sfirW$1ECXt|^bg0eSVg0Nt!J^SdxzQq1C>7p_iAy)mRPQ0= zrv3ugB_&|MX6xXCU}%cQyO_}Bfb(A`Kvs z30@>(ol#7&o@y7*oH-g8Tza4U6zT|NF+zwoI*}CwpG$<8r zK{&hov`Y=CP)OFfH}pN}`A+VxGvt`w{s+y)$M4w{KJjI5V4VN-im%#RXbUr*W^z_$ z2ZLbZ&hyn`!YNjC#7A~YnC?PE9LAV5_4>80k`1e%7t#gN-=CSaK02>$LXFL0?krue zasIWMUcv3}hWM37Q56lMtJB3!&gd1GFU+XNbFo5+cs!dd(X{!4xbri|HRA|RGU$6k zX$BwdSgE>%!CJdcx0)JjOPvbZPPbUxoLMT;VXH=g6<>7{>gSAiGeirW8-HXR3|djV z3vDzGON42ieykKE&@QTWvVZL%WTm%#gy>h>!64L5t#+bu5TW4PB=g*&o&Fjy@$*OP zR*6?hR(bim<8$oXifrxGxa8XY)w`qS-Pz{T%RQNO)d;E9lmm9|FsbM)QZ1LMN;7I2 z_2@@CQiR$j)sD$>A{5#|?N5$c>D@wWw|Wk^(X7T7elRcht9f`jgas|@9EkS@w+;w5 zm#WF0j&bmgzf3BqpVAW-E{#FHBIxl9GMO5O=T_{9@Ys^*zuluE?5ysRD6<_d+YF6v z*)LH(JYZ(%Ce?7!tuUEt?_|c+CWU!kugrU;^22)kQoGz#fAp({cgyr{`d8UKC(H2e zQiiS}w>G!eD|9pi>^6^1qMDe;j|e7M9bp^l`-e-d)P&{00Tk+G0utI~6mw1m#V2MA zHTvy`gX>$abVn2IR4%vOPdz&tbq+)*<=TQF5vhdn@O9o*6rQ|#9BDqNb0FB;l6J6hRrW{K56P@85k7=z>nByIgQ6qJNag@|FpKLe z*I4*#f{9?33W<}uwiRJ?>|CO8L+v+QqskEZ6(ad$<_*E?Tqgth&Rq57DNCV7S%_zh zE%i$iau_XF_9&CA3DPGoZ15AjfSpLpIKn9>6O&^0ToouCkI}-4)-!oJCQc-@Z^(Dt z%VU~XH;=Cz^{x0B7mxQd%03I2D@dt$7Z<;&Bsr^T9$c{B#K;b(H=eOXjcC&|r?Rs- z>P(;^VU(e#)9f3U%y{!G+r8_#nU&e`o8tGNIX*+S*00RQLf&8E&4Uvvd3r&UtTryTfqRO6Gdc!O;q9muKY7GKlNb5{T=jCBQW^^T%pJ zB^L9t8MASiuHj3|^%X-^H7*T-AgtEex{?+R>?6ijU1(0rV$ztsw&lrSi<*HdTN(mf zSg#H&WNuYE5&_gxPFDui6X*w*P4|M!)LxD34r{0&Bs39N%o#Freqjy7bF9Rkkvz#M zorWJxX308hD`r20=2H{`vcGE$s4gh!*)G*6=O3l6ZHetfLgF;2?VcUkUfXh|9d&*@ zAaw>iC|PN|9bg!i%uK+3Q8(Y6OoFB{$%hzz0R1Bnv#8M;IA99hw#?R^7XFl3|DPmq1x#cHC z1ErlDkxHJ$oED7Cb6-XFiT&(ahFfx{RPPP$K~o*de%OXDHTvg8Y9GrBXl&cASb0)|f;-IUF;=u2|T_q!waD1(MbB#t~)K?zZe8o&)eC;G1ruFc%uLE}hL#+E)N&~a< zQD2Jyr?}Zz>t9s?k3cXe6ee#C4NX$2O#U+;4mb zmzmicQLqX`g`67YbBM36)>lHe3e)f zs0jfUp+bnbchZ<2iE*a!x8|15ZD9a_5kAYh84v4*T*A& z+9JNFM*xff53w)WB0%*4O=5Z}ao!@AjWui#i^(o2^yWf(E;-dCphyTZoM`$l4N>JCBeW|Z!)Lit>eAyhJG)ct2j-;-A-Q3x+DFjIVOHdbzxw8{t^T3Hh z@}keodz5Stj0`=!DBC29G;aSs0!#27B1cCTrFBj&BqXFLMN$C3+t!c#R^=4S+#g0D zAeMUQZ_m!8W+>d9{J%#z>o?0@~{nU z`bYpZrr&$DE#giDc>|!QCe#}W8M+#obv@n-k#4j`e74Ursh;ZuCC@@Z6Nlc`_Dur7 zOh$>IK^8P_0x{D=>PfH-%-AQv0X^Jm)!HC~==upe-*@0Yc^aIb zJI*0oZSWnCe+FoS9YCxsfzkKl4D%cBkqcm`EVFRF8S+GmxW^-74v+52ylz&|zu6MN z3~Xy`n~bpU^aoHKihr6rhozmiey-NHi9w07a{<_n4i%a-?m0nXfq@M=GcE1sjx=Dc zVH{9VATUpaSUjIbjNVBTb&L1$2m~pBTzC;l z2`G=0R!5Wl5l5VFVvbjU(k~;cgf2Lvo+_00!7t^m;^64&WgWIL>m0t8LT(RGj0T0(K7683v zCy0JV!5vOe-UXhZQV>(qI^OaQE`B`nfx3OMsCWY)0D!b%KyT`v$`1KGGq0c#b?GgE!1bCKVV$OyQVP{jU@O)o_9U)a!r_A4(O39989jut~H(vm=&(B_;_KqZVLL1*ps$&NIX z#m1XLEPqX8Z!_uWLdJv+ocziU1=rD{I1K>WV4VH+d3q?-fz*Do0z|(a?+l>LkOHOp zxUt^8>)OB54W$KCkWc{&F+zDA*a;O66q8?{zX1?RC7`&VOx6!(5tMsx!ojK#mUw0e zpeSN(1GZc_D@*^gS)sxJCI?u-{&5$?pcX3*gnKycOJ#uqse+Hq@Es+ft(@Oc%SQl2F#>L&z)Xr;ZAh0INKs3Afxi* zLaLdcb~tLMFxWDtAv*@v!@)Vlq*~V@z?omvN9XV6*r5z61K1;L4Zt-ZG5I0sngle> zxuJ98Ek^`Tg+_r0gjCN0+MSr*B&bd%)I)&RYj}JnTA<_DlAE2vijf)Pl2}t}%dx^hCktr?VF4CvutV zUd^IHp)*oIx&8=c%c8+zpu6}rI7<$cs07=&E}|!pha1#DgNN6_cNoBmle79uk|UNx zus4A$wG7mAK>uE#3Z5JR0K22uPENj>*ST#OiV6%Q3LimVf&u|q1WJFF!GD?nP_{_` zN|b-}`=s$NG=~BW0p9jbAdzV1p$tN~6|lx0>O@pXgT_OFN>UvPRDiDM6roB7t?DER zeFh*K|Nb%^pj<&i2arWT=%{-Z1NZ@10tH}A0GOX0YYf^IjOafV`p=2~tU7oa*w;Xj z^T-bv&PqT*4@QV5ROlH{SO?bp|MMr{qBE32*%#XN55&R|0RZaGlml3|1c0gkPx%J) z&;NIN{@SiA*mxEq5kN->02PSXsO$v62QuovcTh0Yyh@tN*x4gqChbODlG?!J|eR3LDcd^L?a%_0z_ut{BL@^$iJssT>O&`)dK>c z?-&8L7?l=|}2y$-*00-h;-3j$+fYNDZ!2qT!%F_m%835`yP?jlh z0q;-HqO5{%(QgD!AoBzF0^Fd#zT0X!J9Dx*tSJC}1iqv;S}1A$+1~#nG0O?Kp+d%@2YSFzJ8BT7QGSFf6-u995}aMk z{w@*fVBEp7Z4d*x(%_eEP@o3})>(Xl5&(B;0RH|i@sI4p!Uqx?LOjrPXC21Bcf`N- z&i|fhG&BwL3?vMf3tQgve7Z~UCkUMlet}Rm_)-9Z5JU$+s9Nq*{8B;vB0CryV5_uw zmgGRG2k8E+|4di@@CO~T4I!9`VBZaG6&Quy3cxM_4@yLSTn(rpIT|at$(V+)utfy@ zYIQ)dfmSCm>mKmj8kD7qdOqm*OE?q~zFDRQbO)QQVi#1iH4i)*?f~HeoeW;Pf}s8z zh;^`sBS?d^+=U(=bbdWPG@?{Q)lQHh*bD%59OMDI`>#)+q_3hlfKA(9pI|b9%%D)M z2VwP(P(^79hAMP4s@ntAM1|^qK{P;CsZ*hy^1OErg`Mj0>bNXcttc^`DpkOw4M?#lI616_XQnM6h8;g#q}=$jHEXW+mXM36_vl<7k_P!(aiueE}piF$#`I)B(DF@+u82{ z@UmYmmklC_v0z&+L@(|;*5J;LYr81L|h>7acMV zP+g!zgqF-%0Ht8IZWyS-&ma#1y~^$xjG^FaF$V=>^C-D3liZ8X&pu6Jm51H@g;8fh z*dzWyIM<=2GmHW{$Znv~(E(3h14?5KR2n^j5`pT`UTx-yXGS=9f&JA>MWp{`7z zkf^Q%te{9$fpY$Lq@whMYBg##7^(ll;=d;<3*wh|Lfr(U zM0Qdb@OMTb7OumatbkjRfahv=uyKisIWD~a0vMG?w;c=bq8c2?7e<-X^rHJH=mj58 z7On(=8L9HSsG_X%uf#v}MD;gwAe^FN0m7AkG|T_a!Vn1mv)TU*5ds_N9pg9HprQl< zoBu%Iv)150Sow!)|6jNexc5L&`v=&d%=jyH&dSTb#32Ya{~Zwm8z^;tgAHhqGq6Dg z{lBsPSzY`O{1gVfM#udDHqrU*e}c{L0R%QyzrY5f{{c2}XJ9jW){Feg^Z%I+y-hs= z(iPNm6584UyjLp9#{<3hDO(?1*#ieCA*%nk23etAHbGWU9|uIi)2!dm4`+IUV1il=!OlOk_!~s< zhGC7mVMw{pGkB4L4-iMFLx5g<<;C*p$si25Sz;YpErLEz2@^TpKHjgU?9Hh@+0{9n zU#~vh99=*42&9C$PI#Xj_jX6ahEe?<7VsAHhw7|chy7R1{e#N?sk~6_hV1`GI{kh| z_~Q_ieZd_2+r-~t_B)+WQ3KWPfAKI_(ElzGdi;Sh>yO7DROmug47jeGAsi~k{;!Dt zf5jD`59;QM%JaYS8C8D&wx61S?qnQ*As+ebCQ)4Gb&Kmz=5~M??diArd(Z-;y)&5h z1>{CUYrk6w;JVX02^N0@*?&t1^Kc!=S|~6fmiEQaAHghu=Uuj6&;#9X_rUjZ2znqH zzy~2vYVbkm*G&n$M1zk$D49Li0O$dp&iPNY0I+}_efEFf2f;@l=w$HG#~JEKpug}y zjT9l&NI_7Kx|o9(CP+mT`hX12&<7L}g+3s+GxPx^JnQa3R%iVazzT}fa_EBh`;ii* zClr#X)nGsIFD(9hA{3{P%75S=%1^)Ses&l5XC)v!`R|Az{y~Am^ZSLR9I8`LoT4lS z;R&iu1k3Z^B|_aYaG6ig#~go>V@V1fuDXy3e;Pn zg7J56g^J$4;gUbY^51O#opXN_15^tAKk^gs-z#Ccd$@c+s)ZJOejVjXb3Nl!XisJc z<|>|5pglhUN2C}e8FuOuFggt^kGUIEM@Fs@6rLWpaeeukP!!l?EYenJeDSKA@Uo-l zdiTkbCwt@G`$VFASe@KT(s` zYdNGmJ!k}~^C9qSr{mrEQ|JeG&~Nv-+iR};;J9|7OXu>@xnk<+l*Rf8%o?2stGt(& z$wJU)&2bU&v( zx^!Je(&eFQ^I{9^bC0O-;qhV&%QLB~OEqq6(FU`|vd>z(A4ol?-};cr=zkjifv zXwOq8@exBrSB(aeLAWDsdwX|iH}n=9Hd3pQYP@YTeIWg|#^3WaZn&P`X5$j)ff}x8 zLV`}FF%E?oaqHnDT=EOL-?Rr82*M**mU}mFF7{gsN?V6TYOXFPduvAPp`&3NNX(8Y zh2DJh;6kM(2Eb-1tI`94S>idl6ZU;UPzn9~QbJ?6L zy3XK5oua;n_XVjvdKLn7mbnp6rLU=hmP{|yHE#0DQ*_^_ln*v>@r1p|Llxj(I35Vf z{){7>5ID!}z#THTu4v9mEjaT1Th@&??)%Iz-pf;E*TT$Z8E(aHs6Ooy3{R_)R=u(x z@ZhrjPn}OJQE!*Lo7!l}BpcJk!kC$a-VrkE_)j{a!_%pnSotrA9pugs++wB-Cc81t z%K7Lz$?fu$cNf#B&fl8~R>ndGwd&vuFt=(j?(+t%S28;>dtEol+hFXv{Ko#v^S&61 zhSFT$%;oBYny0O6--8v`B~_!s+&&HIS^Z@E#7geLB?gc5xp4Qk&E&F}OVrZ2qoprL zJLv=6s-K42A4)&l=_4cP-6!25$gp~SH}j1HJXp?2y~Lyths4oFw!&*iqpz%;%q-Sc z$FIm}P&s;pRQpoq!?wMdtOPut_b!Vw{*L8Z8JH6GId%FkCcXEhHC!FBr)|%vENG;D zE)^1G6#Lm@%r=!}@v|qQ`rqcW`K1E1nhR)XG8AZN*ruX-9(ME9)61&QHEuk(vLo9X`G`f51c%TH$4^b<~%(m+E%JjKGDfLm<(oM4+rH?HgWkZKA05lzJFEWBAP|2aJ4 zxyI)G$&2rWo_JK(tCD>1HvCu`Wye>X$ucp0!;ta-hwCk^=I00Z6)iF?7&z38zbeVH z4i;l72VUZ`$A)uPNyr4&MW197ZaetXs^Q7bC@ymbb5p=ZIM})Z&-eSlKMwKpqfe1w zDta%B5c1y+xY+4JE&4(`h~aX;rd%0~xxvS8Jr`oFduI;bt%?vIiH)T<7hLe7HAA$? zytSd{qM382*X7iD{?&p5`QspmyC ze;l}Nq$GCj*)uoqpq9uNrO#4QKG>g+c%Gtub3}UZw!&lCg*(y?qxXV#Cf|SbpoxW7 z5NVFuU>il|R(-GJajM-J<4C1fFrhTm*$(6qA(V`k@56_=3$_~z9je1MYX@vSU?SDc zII}`H>?3&#C0}4@U22y@WG$_R+WA`gBb;*Y!= z6K$Q78;iy!Q6D4=&G=mWlrO7GXt1ok^HHlEViF57;pz-s5kps}Gr?yidD$M@CGSnp zjnTW<)r+?~T=5JTAc8)MTIUd4cs4cC_=hYp+`d@Oj8p?rH(@HvH^` zEusc6kCT?;GXvL4^iEwb_PI%D-lQyVtogdmwV(C_f7ms=i^CTEux4i_5xd({5~7y} zE?Dx5KTUYnN>PuQ9(*TdpDFo}&h^vLTOx}jsV~QcIFFiw`)-#U`yOa{%ptbCdvpfh zEoXmm6#nT^sSSS{kJhp4^x}EVl@6q%a@qvO$m)XP?D?e&YLOolp2a@N*S+0A`tbZy zf?!s{8D@v40^(S9SOPZ~?od>; zP@!>%_$I(z3wEUPSe+l$fiVpNNnhQ$h7BT9Z|>4d&r$NQbt z$ipLC7j~xbhx$bfPp=QW?e*^1{n!$|Yx(XeyVYHo$j2AanYP}0PetN=KdO12z8Ew} z`f>Y{$u7R@%#(~u?Mp`mvUB8~8NRq%_81BeHztR!UXQJ2HFi#}=d^PdcjsMk^<3GP zLRxfhR~}mAy&&ZoOgzf*6mre;s zP2@vLlX>onlTL0EXxQ$xVw3BVRd6k%28(OV~MeK6#BRr8|SLfla#%u~^D=y0x6-$?uTLZRHxE6yoh)~lkXxg9 zo6IzWoA84)E&Ra-X=yh3V=IRpL{zI*<@mUdmWmXcmatM8$KG)N^0)QJ`@>u*o!Q!m z!A>cdo>%{%sLkAQBYt!T%;V&^sC#qL%gJyjLXJ>$t;`DvXG1EV_Gr4tcVwqUxov$O zLzK)sry3r6@QzO%`F$RF6ktw6xFO<_8_>Ne8j>iGqr#N3tkk?FCX=HYIGNoSKOsxW z;;SMuS&{ct)(9uo9<%2C8asnPdE~p2aAU3-QcwH0EY>&2s5VbkJtB^EFYXU#=%MwL z{?I5ou52Gy4}CM4TUr)o71bMeHCTOl@NUw(B!;7!#z%aV@Qs0UW-HOXOBM&Cgo_?2 zeHDD8$KLjI&24#Oh!6EOrn9N1lVS4(rHf8DfhRf;NiI?w=&+__*=6pnC-Gz zk!0^8hPN==i#j7t`ghjID_Rp~WU_o;b-8Hcve~LC%=6jw;>s@0CCm)R{Yx> zk;nSgf#N+|QeQS#6*+^S6=^j@hm19l4IJC$**=z=Q+5eB9N%ewM^*)kR1T3nIA4=I zs)+m$SrKp9M}Ih$QIIk-C551k!B`BwA|kEK;3A79TZ-S*?= zJ=Xo>iJLo;hj=)YE|fLZ7Bg8JmwU>2#yAhn_$qbSr{tR*gtIPi32+)7gtzij9njB? zJ0!QjY-X8sR&5xDH5JU5g9*!Hyld>zSiZ7*M zn?&TJ_6sH91T`7lug!3pwsGQzX-_yS6W?XTQ}7#KQOMa2!zYR$0A0R62&jgTW*4&@9<+S4mp|chFTOdsyIJ-CgS&Z??y^o-4YYnxeG^ z@f1{)^Y4bQCd_&4d~-w79}{HAbww^Jzz`2y)8xwgCpo}R-P#^^cM+`!jJ{GNZusDUySL{ApAYqd zt@cu$_KGHva_8)IR^J`8$8up9z1hJ-?Yz|<_MMl0RxDR)m;a({=3N2q7*add<089A zD?j?c<9;o%5Yr0nhb2Ceqk-uZOoyR0DdCxY;f=d9rjL}@+69}RN}`#$VKVm%y`J#J z7ruz~klZ%PCMcs%u00M{ZqZT==b&A>=ISBM);Zq0ELmEa2nW{u-ORKM>sWhIB0&o_ zv}c*!?P&GwBp9@K<#D(zDKkhZ`0jPx%Wsu`j5tKgtUuby8Y`M1y4SIDccLV+N-d9F z@H8QF{%}561rx(OPDfAw)cc#(>#`w4>~@hs&Kum~iW+Obb296L)TtDSnWgGhWL_`D zIk21wd*-=hSifVOa(ue|l5V)3F-O;ZMp>rmoa$uF!KnEqz-L%QP|F}MN*$*W8{SeldrbFdvO$lA6}Hln4aIn~{D7<9(NzvQYn zmR}>i{CRQd{#LFxHt{JNae8BRqkoJ>O{2@Jgz=BUihHi_1vh-YKafpz4u3CW(a(|R z!}GePLCJq1c49o3Xn*}uys&U>t>-wpX97mYnCA$(@mkm3cUKYHm&#zD5`MwP6 zmqug+}vg>#ZvOHLOL)D+hBqR=lYzpPPhC9+;QKalZOZmY9B zj-8{c47-VNSVN)ehxV3T_`QRE(hw#6(~DSqpYE3l$bJ8QRQJ6x?mR<%w*!U!xy1YV zr9`7t7tC$nY(k#y>$ zZC<`mhjm@wo=&tb=1I&}#dWMP1AD3=uij|VU=`^Uw%D&r6%w~1u4ZjbShi1noT^Ej zstFm5MS6zh@tlZHiW=-;rSn`C&v(y-eIOJ~YKYzWkikQ8ZCA-OAw4LxSxO~ddl~rz z&3(c#ug$FJ=L_{my4Q*aGqP^Pxx0ip;1fKbeMRKAw_!6ybnS6yzbpKF+r7vUws$O+ zqFZkERw*P5IJyA2Rgx(AReRv_`~^`f>XR#C96S=xroKzgTHm_SMMLUKjJ)3mLo>baOdVPu_9O8lq=wwk;J{{udK#8qAk7X*w>B z;wPRb*D3Y5Jd{Fsr`YsFSzX1aOS|V`h=XVJz@2eJ3-s69V}xCtQ^D!IIvas>C)I^^ zFAm;RBIe@j780k_ru+je1Hz=fE8HWpI^ALW9(FDGwTiduZ5>@FnlHl9mgH9-d=eU( zHap$&KWedC5R|!F#gbnv!48#mOaiTXku2o`_`^*Yi}>tym$~;f3nmF0Qel zq)809DZJEuqlhIRxaPh;X*(y}+`BOT>8$Dt+&srjm2do}rRU4xR@g>#b24?s_rKr3 z^l)czJ$Q7SL!cLNC~ncV7~x){d;QaOi?Q-i{udXibX7aib-qQh-SYMOa>c^auJgdJ z<4RWhkDliTed9l*=5asxLPJi?E1mGQ3i>xYkqdAyWlYrBY!c%!mChI#dZKwbZdd9wz2-$Nqm zgmL^Nk*sW6%>Gdhrr3w@Q)~8_Y9;Z?V28C=0mX$_=49bWAHE@?A-bPbPU<8(3ia*N zk2?l8LqjN}qQw;P*Cc$2r+OUp{Ue%3wv0$&P1PJ*%u-)(rF-0IQ>f#+&%jT`hn!QCb7Wpiawa)tiI5h=uLXX;@*LSwYyV|&A~adyK!iX z6i`x+C%j}LG8#Z9RL7Nux$BZ}WVrf8i`&%krfK+hwur0e<&>k}wlYy=`SFX{bd9TB zK=w^}K9<+X{@#dX@sw62JlML9uE<~GsFw~ue9_G9L1O?hK6bpV2+fmp!ked2Y`qje z{NHG=)#4L|4oY1hmdRx0*D$6^5Ph9fv%U1WyI8_oD%|KL|?9NM~n|{8cZ*s0}4nC9mDP{^6?}H~3 zn;>WQ)j|Ti`259Nsixd7i}qm##yt{-dX(YC!nJhM1?7vkl5Fu(t;l5t)h?v24%|;e zwq_Y@PL`g=T8e$&J!CF=t8Z|p! z-0X}VvxQw3N;)}qStY%S^`wqVZ1S1dC0VoOOIG^ymnOJeG;pi89vPhCJ?nZ%W)vi- zAQEs{d_6{m8jsGtORFLr=PvomX!!=;+v&^2THBZgbxVbYVK_4?VNXS$h%{kSOPsrr zsPmR6xy}0AW5L}@(?GP>E`1&ehh|%sZVAiC*|{@+5s}YOqJQ7+5|fcFxv2J5citgLgPAaB zZtAMXCo+q8kbKrud+_yvlpe!{)!VN4{GGr*gr;+XfSq>2F7rR4hx^@Pg%7y0~hmr<1#`?K?R$2%cB0vu(h##jWW z>M3($REIndqt`vhhicKMqWZ7&`W7Z}6BazRW;V8<_HkegFo=jU{P~uds#MtLYHN!x z9yMMr8qMO@i1G9WllY;Tjoicf^AB%gef1NCd*rfATIfa!RNW*|^bOK9z)gypDQKHB zsu-I4P#H(0!CkVGH^^=FA*VY|3P~hGduIauZP6S4HP$z_7?@}c9OrLUceBp38vH~b z>Xp!w@~1g&c4gzXeC~`J_r6|rXu$i^^j5if&{gXsIXFSn+wqu4gO9`fcb+JYMov2E z_PF$4e;u3iL*Q+DtShl!%ajt86xKmzADw{ji^KTux8F#6UGAdI^_dWtm|{DxK)c>7 zq1S~kD8dWxr$L{Q)!!mEY)aMhp-O4nU?7U|8xJDC^3i*LEpOjxe`gujbH$0z(WpQEFo~$XnK)x75gsq~fsOyVa!~l(b^Pxae4BU_Zq#z` zP`vUiAh>Aq{La%7qQSmG{xz0@F-52Q$V;wF+XXL}r_d$rZcW_MZL-Sd_(7_UrQI-nVynX6CQ$R&x!>%f@ZX~$3=Rjl1IW}p# z(mI|dyf=%pdTDQWZyzx<=#!+NEArJ?Tm*^GMdObv|6tv_UQzmD_8iMqvif&QSL}is zrzNE(HecM+EIhoc#8YT<4pV>;&A9Og)!;Ghp$i4$cL_~PLY=~$#4aygx}Y9U->san zc_ItavJa6}Z`UH;XA3wdeYWhXc&~Cj5l1;Gp#2WU%Px_Pz(R4Q=eM4k8%mmb9TC;G zt0la7VA6e6*^?BZVw4(KX{t5r@;Q0-XS(fD@AbE<${7r=F&A*YI&VIzdG_6>C+0y+ ze<45d+=E1_w;3Ko&v)+F{ZlHmf7F@cN0O(68mvRx@zRrjC;q1xny(zw4j8 zRSZ8D;1f*f+(Ys~!F4qxQ6@t5iYj;ClOSJ1j-6XI;d&Ri@NXdBh98FHnm%lJiBmo{ zfYWyr-(=7yE~wABM{{m1?#)RWHNG${o>s-$Avpo5@6aL9q)GwahswU6K95p+dBP)V zeeh#~;`U`1%_Echn(U(!G_J!KxdI3CE(fS{)xCD`JQ2vDd?z0EzW_5p%)dQ+Fe|BU zRtL#REd~?Z$GxLu9|5D~Z)DbgIp4%Ty725O)*J(uu)R|Z3Ft5xgFxzQqO`uSK!}zK zM@#L`AlhVEB=)d)DvU&Y#Nu!zgS3WvLasNH?3$<08!7#qwh<}KhWjcZRdK6X{U%n8 zJeA%~7XS7=F`(|(BuOrK9(_g6m7aSc{y_8_YPuR0rA&}ypU4F|P2hMV#0)TD3LLo& zc@X^^IMa`Qko~x?b}xVwm0I+pnUeU}_##spzR zq`JK@BrN8o18&Xwbnt_2B5;u(VR6JC+{yXL=@BXs7vW{#GX{z8cg@T6pY0zQ?(cM{ zvA^5D&oOx!|Im)$-r(7uoVkdfPZMo~I{?QP>--IC#e%g8R|0iglB>q@!ZApTmmkF0 zD@Zpgtz6;G{TR%DK^=iacMK@z5%)M>bdr>p#eMQ`BN6)y;J!3LEV0M3LQc!X^l9p? zamlPMGP0C%(CrOGU~B@AXZO4V5WYu%^F&s)0j~_6i0()iGsliV6$+}HqfCZIbf5ACb+Gr z#W>=91o`Z@FqtB~h-^=pBOp~kjMlLvh&1)qkEY=RlkPU|H8Uo02D&nG7&~l!A*Hyi zGr%6s@bk-+bPYzJq3sAcNxc!;-n!c`ybLqemd|F>-B`lB3_WyVgRy#k^`Y^~SuweU z{M6i?WEx*{mIuy)uhbDZ?Cb73+tWAD-)S>2f1hJAFa9HS1P%xKK=;Yaz#ja38csRt zUBl85pR0`%V>IeH5pJB zEXlD8gx0w}Mekyh?#x_so$d~8>vVNaHa8Zs)o7VirOrwb+Ez(!LnY+v4#8*7AXwCW z-$&v)yU($S>+H~uz-M1~KKO{APnUS1VnB$Xx*XvPG0{xj4O%exu2CTjQ-E>^x7kI) znd(Gc!~250 z1$8SDR&v?jVCCnKwf%m9SCmXjvd?Ts(UyV4GNmBW+F!Zk_~7=W(3r}lOT8y?nGGpM z)jGXq$g#zxyE68LEpotroHrw(vZNy#=_GC8q`Rqhv8Z5iQmE3^u~l zMI!puj-_#Kt`H~5E3iN<&Vjos9?+J3YfebE3o2DOOD~>~PU?t^f;EnM!3W?5=$w!& zmDGjXEt>wbn(uVoQ3pSF0G<+ttS-(yBAm!2?Y+^*#fOx1yNz8mzW`tMYsMCt^3s4Z zcl$D8RCWbPQhlwOQJTmqIH zfBKtqXMXmxo}Ph$pZtWs9{(8>EcCd47{4@fnZNt($dyZ%|DwO_V>KRCHS5nF zNA5NZ0ieTeWHu4Z4@xv!G$Q2c>HSqeNf5x0a&y#OgkGdEeVrbKV1zFsfi>+4@Z1}? zOb3if)C-#!D~mv%%?S9+?!|?NcrENO(+K+2o--2?0Y+pqfX=AAW1*1xv2nsx%uBO=YL*@@3J(Y|2zC=oeR^cDTOBXIy6Jh@9^c90sCM#p262j47fegsa z7tQINAN_jl>K`s${W~dHzY9#p4sr8K*;M!U>4=%67jjJqH)mNOm;ylVr=4>3ktXjO z?Cm(ovd_EEajw31XlGpg-l4p!-#c_lTNTv>Ov=H+yBn0^Xv7O;9RLU{#sviPnMjT) z!I5~Y0zfM6=Uib-dT_c?88jWTP~?J*L&Dt5L9#hX`{Cw8ti!+*LL!8&5k0z$Bd5<3 zp%xNnG~{R5g)JtDXHMen9V7s*~R&-#4E zWWe}NvTSYvafD%FVw;R`TAB|`FJr!?f1M#n#Yu|3Ny&IimE73^R{8T8(kE9~iutsT zgzRyA06wuKo$QJ7)aF@X|2YTOJfRzQAyq2rK}3`M+Zp5A>_Pap_{W7YElt3^m6&*b zRz7Nx7SuUH?=8K7DWv0PWi~b=qmHU?Tbn4~<3^={@{4H`_uRrpEL#Hfq(GkHx1bk} zUn3;2Wbm!@i~s!7k#S%M$i{QfGQaLe3ExsPNdiufVTL<)fg zw0Rp#WkEc01t@_FYoi!LF&dk0qyl@|4^j9Da~WV|T*J`YS`y745_KMmLFL~UdTCN$ zT5~S6;c_buWLuEro{!6yw@n{Ctus1~@j}#w>~J01sz+vt$o}O*atyQ;>%6(xAr2E>W?baWqhI>f>9Rmt3V|sV0>onp zj}*2jz1_dKoaHNI23BueYFJ*{Ne#;}SY|P_6ra|seO8ENxDH1vZ9uEyG(%>HRmwOL zlf%1}jU}3VyNI@VnWA=OILvy)b%liUW5`0H-&b3W)qb`k3#q9oVRGfJGIH|hjnPc9 zP?)O^r_=>8A37v0#o_Kr74l;B;Yp1WgAy-T0~vCb#9B&MQs5NG!fHdY%gkfA*@}pV zn$W^`*Nhbk@2jYuRDI(t#;z!tksvU&l}F?iuksmKrdfk0BaEnM#Pi*wlEcG@-F3VI z2eP`}eU1}m^A7C{t*AF27=+sIJ5=DbTf|c42FMYvq@zj(I3UJTa9Ddp!wlmhw^kVG zcvPDVqmOVLj7Xc>XebS9c^+}SG;!Q=Rfz!QN{h4l7rmb$*~Fm@&}bDfh>}LhGKgOy zDqu+m@>1~#YLx@XB#8d@bvbb$4Nj*oTH$Z0s3>pP3 z_-1Ce#r!a{fbr6oTS!Wim6cVuYEsBt1X&oPV<&ph6;oTtDjWuLviy=r^tO)B`>CPn zI2qg&#+-xS3Rc)I$BsnD1L)#8Jax6?GocgBpPrRwM0C{P1yr>wHJcn z%7?zar+uDrg(47H5UoFq@}$0D9Uu!WexJ=Rl4h2I9Q%l2|nj_gZwIyadyGW`z%Q_nsX_ zC1Iy|Jo)>yHQ(}wcE%GO%A0RPL#O5OoU9q!tw^0b-ZZDh&;!x|qfLVZ*o7jXi%^}TBxeFM8KN?>63OElW+a?>Z4p$6 zW0L4KRXm}>WwUZeM8eSqf}uK#V_ZoXXoJcRAfF+X(OiFw5sF5EeQL{~H)>~EFZ85P z%&b*2!ZD@;^UV{fpe0M4#)uANdV>wUr1wO+cGP$Q!-p{NMC>2A1YSji*fh1K)|(br z8R0M@iU#{l%@X7vg=sCQ;u`)G;FX=ltUiQPhOlVBD3lI8EaGAX%wCzJteJn7M{j}; zN^??)-b$Z>v?vMpQeQ;bJRuaWJvh&yXB7R#`1Yxa2h zYi$1PQNj-=i5Faq`X#(x$H?KrYw7QN{=&b{N!X3%kvha~M2`l~EQCnU>73eP!iy3_ zl$Zdb3)X}!S~$^8Z6>?U^xU59(uu-yvC~w@E&+qj*6V4c9-X%6X6VE=sycDNQf^8y zF(uPN;0s!ciW22GT~Ej}$<+fF&`biLDI`f}x-_Z|=%^-nE6?hAVMXT%fIc*V`EehL zh5|E2mOKYbvO_fk2^GK>UhI@uO-l>JvDDFPTSBbboJCn%lZ%JhKzf~eL^ABP5wyT@->e^Tk zq*ZjYdyRvT=xXbrf{CB+zM>JWc%LV)*FZ}nmRwPr+5~nBvw;k`WZsKo|2*wRP_V1| z6?m=B?W`FM>=YF;$J8Ej1%xfDI4ENFIs`&o9-`8)k%)D!o|#*8+=+3IuhsHLcFxv14i$ggwTp45-)8O$yWB zXzfMH`=tkWFqnq$B{*rX!oH}_!(q?#^XI=4F9i$PG(kXAej3urV6p;M(0XMaYSyy& z^JWigru5=)aa48Nbbg}}ww99#0WB6;vrbV|e#N9=>@2`11xZ?LIW6o8<`4*M^M@tr z-AN7^Nu*YC&tAkx(1;uvLq%vbgQD0>hA`tNx5n1(y)7BT@T&Jfc0=mWpqRpBTm=yM zk$rF<5E~LzTjJRIBpNT13@(&N{FkhaD0FMNuLW$#nfgI@R#-lI?Ff!|9<>uawOxV7 z1!5>ValwDBVxN*Ha^fzyJy?23?~tFH$;D(8-A*xZwKD5_ieUAfh=J=rQfJJ@p}gaY zJ_PST%UA~U9(?Pt)W8a}r{ygKu~ang-XgMXnwv`E6s26~zJ+)OOyF$W3}md8b*8cZ zZ8A9vbdGt(1eCqJF=iSsz83voXk$s&Wq^QO+{=Qh`~kp=0>vmkhmS0LsJ5o2 z?Jt0R!%Ab~(vsLf2?3*h>x5-VZ?e15E`7#w*s?$eQ<}+HqdE>w8S$ZM&#~b?LzdE@ zxV67pEJbHr8~Aa?;_N+R%LNVri^l3uiOAIJ@rEZ)2;`>@DK|n`B##!2u*3Qnuv!#5 zTqQ0_DLCuI?svrwqD{&Nfvh5fnIK>&$BijOxw5IqllD@swM{{P6RHB+$!`Ms&9z%% zDZ1y+IJh!WP!HsWRz}ufRcgv-Y^ei)@jFz^VO!WiN$)xykB+qX;uC^Xkb-H6(* zaLy9V;S}2Fc$;BrtcM_L0Ecp`9^N2l9Xl z((-BaV(!8@xg81~A!cOKS~d#+FWS)RUq_UteR?jA^ihroHVYdiSg&dYS}ugt{{u|} zBPZ*UhpGaoh_VXZJ5^TO_ELa=p|_jCuz_wa3uCc{%`!mYfVse)Dkv9%lPUGQXmBL$ zP9QwXT_GMibgY_vUV{F3E#y>a{uBPXG?Me;94;A`h3lO)DenaSd$M^OeVx2Ihj>=R zlk+nXH3wN~hlbJFl@HwKadV;YE ziJQM6V_-dDadfqQAYGr82C@#V=+@6u2^F`uRm*xSVFG%}H|#?tpUEg!s#!1LL|+5? z*P~aijmf**gwv&Ft;#~9E|9fcsut5B(Lbv`)p^YTm99FGW`KWahg7ce^p1hPQ?qm7 zY%RdZ5Aeb=QQb0xb++!vUktIupU$TiRAv zz;t?@s`5}^CZ$WeNU>*-#0&zPl);N7=zqi;UIXKhWX9VE`=~NIOo&(79!m=-Law2L z*;D1Y0iMrx&`hYvJFmQPHNpr4Vw7g1odPx7xm||#sQoDI(%Uwqz z7@I~|K|VK5Gc-Ux)O*iG_jeDm3ajI^xcq&NNz?d`)ETmHcb*nE^mep3Q??2Q0XN>p z(JuBx_$I+*K;1s){~bu+Y9mgs=OfgKW?T|XQd9(xf zBsec~w<#BZ+lF180#RY&LmQU7C3rYZe_mF@@*=dN)z>Ri>2hK3<2Fq$eL@}HRMsz| z0a!i(60LpC#l!FvB9SW~Ka|3$V#)g39^ox%w?B*Zu3fM}O0+25sP=qfEw(Y1OgO6-;BV)2oKWGQyvpnZwr)SHYI9KOLYNb5fyD{c1(x8W zcGV88>38E?bCM}Y_4YmuVKEc(XC0A0{}~-rMHDn7N$++{Q`+EG$3;=|_c;kZnn&so zKIXd7^$c~NvKt*glXUYeZzT_@!5rY06*?t>-xkJ7l&+1`M^;6H?()EndwjUAwH-!{hWu;-uxbxTQcM}R=ALL3RvuX*Xkwg+8 zXu_@$Is*F*fsRb=b?;9Eq*aj<%2seA9ZhYg_KDgUhJIooinQ%#*G?d<(62+8=3Nr?y zO|z&34)^9FxJ~v|}kh5N_yPO|a!DL4h9q0bL8W6#JwHx)WHdq_a z)~E=O6j;cr5S|bFrl|j{Wzj$UK{oL2(`!OUUNqOfgga&ql?@To%Zy<|0WkoY zz?P8}kJvqwY?(aGst6;soIcO5IMp0aCNP~gOK|j39zkr9>k>!?@}dT>BzFuChzSm_ zv{lmwQOJW+*OF6#(5Li1i zX^~F(w?nB{MWuSib<|{eL>S8SB-Z5`h->1su!Bfe?&{+ZUZ}{R%&WO(cA^MX7h3qz ztK$&Lu2TGF^#a=&@>$B?tJk+ysfD7KKIW|=o*@}g96+O*D3M{dmc$q} z`doP7qfGT7w?MREEg8M{H`06PcltoK2{aACu88ejFf+U9qb{ix!SA$R!tJkGb znj~2@9EP@y>F4YV8$^H9jYR|-iy**E3B(EQM>axBqS`~ET&|qKr}9_<5;P4#6AGT5$)roT&MBxzi)>AofT9u6+>tjfYF1;cV(_2I zKKYlGa$1qx3+%H*Q|6hOh6_83r@4O6oPj}zB0XRP!wX{cWI38PVG9!hh*zBWQ3_XI znB7(b1Qg~}a;9-niQ$h>`k^IbRhE$LlY1{r0v+GGm8Ar{LYDQ(5W&Alhgf|1fLrg-Th*cZx`J;-;X|Yu zCFyr-vD{#uYm(O9#P}?ht0vIEoE=gEv093_Wl~7s^BE*!T}h=R0wlI*!mON@_z$Wz zL0Oe2<&=t9A@FcDU=QmOyCgD%08nHI*9$TjIygAks3s zKdmhkwoNctEpn{n@cDqQ27oZhYC6ZJp4q(@KlokL!Q7%mh##6njS43eW+^`{UJc*s z?h4YnCI#9$RPWkwLW@!h$s;Gj3;!Y=Dp$?p1)92@vL!ey%jSYet%kiClu${A!BWHuym=WI3VaNZT4!6H15^k zw-M6QJ>1jV(_h$(;Y6U4yGUo;l9o=1gKyUVRSPK4tW}D1hsPhj5JCv4Ezg&AluK$%%^RI%v}mQQ<_Yj*^d! zS|69JS4M0NlmabrioBn(MuL^&i(q36upGgc+(zkv3fRh24-iF{BEaLuR6ax&cx|eL zLA&3v%RI#nv-spf!Yf!0hYFH&^iw5fr-zN#cK*8%F0cXHZ#IP)A=AKn!XPW6e-90 z2<(1GA=Xl00bmH%-JPLZmoYmasXL8GkK?Z?L*ac}Eb5#T$~nG`hocwijE6Ja;^7RR z#$sWcv^MICx+6I0F_7)y;m#ih%MPMV&)#6Y9WN#Z77z|~{Q_QX#p$V@XoV41^iP!e z;WVwi5=diu7etPc9}5_&ImVOn19LQuEwQWU=Q>7FEQiw3$Tj`JmQ>Y2_HWrK?+aU|) zY)(UCuh3RtK()Ry6X)z6I&J$_`$!^$a0Dxjq+Z?_QEQ15 z0xToe+@LmIEE>No#rSLmB|WP(T3-(}^{uF8n!^SJXWu8cwU55y4bzp`x31j?3u1^rGQ)t=(G94q>bl{>R zpQ*RhT3E7RNri{p4;gH8TLF5o7utNS)CG#s5s9<+pAz)zBsSfz$ADbY=labn!PkDb z5RGL@zEemye~}Jxgz{K4)O{)}dO}(QKTDY2fmRBXDp`ZgvSCw;fwI4NFFH9GhuMPV zU@Nj$a~Wo#;{}T$OA*);M4OHqf56EPihm=F!KSHRN(OxzHF|8R4w?|2TIhjMB z{TrqC)Xeu9cY>xE=v6ZTIrvex+`l<zgdJ6{FprpPnqj1!mFa1EIK^|qtr1*f z60oIaV0U@i_AU#pCD6|F$TG_k$p!BehF5u$Ty-<>t$Y@Rf8)dio}#PGah<60wa;G8 zTVgY8*!Qq*yRC_jNCQPWKk3d>!5(5duhE5Pk7-JoLQ(9+F0%yNl>la#KM)xVKnf=` z8Z}G-Yu>NJWSNA8)g~vgW%3v45Pq}>HlM8Vx>7<_0~v6Qvi7~IOb%pI9|mQ0|LvFm z{U3hu%m4AyV3kWU*j$Chr1)m+-@v-s+SeJ@v^$R*kNR28TPP?h^Pf3=a?hSv&~3=+ z*drv$W=_BcF^_vu`45&$F7ckbYbkHKa6)>8y~y)*@f%yUxxdk1lTvw z-`&Ly?4Mp8o#^__<&g`2_|53}_+LK!&B$LqG{67x#HD{5JvY#OTIAUAQt~?>x~3dSUmU`LC|uk9{)nhtEFwMPKhfb@zPYe*a1LN7t@?8alDJ zm>5E^_tq{eAW;q^$djWAq*44qp2)>kEWx=z_M`?&HhBu!i)t|5wVI%Y(Y0N zWHFt7sFg5T@Kz|dNB2WY0;q&ma&uYA*fiHI)R=q*Jr^y74S>L=DL50C#RgdA z5eic41uAEmP>gdF@$N7HfT0JVlcn&y_R2r}LGV>&-_mYDU~1zWg_!aNXv)P_DYhxf zNd^jPu3d18*t1I*`j=M$>z3-D0_7M_V;2)hA~~>e>{qlOKym}5-+i9aJH@PmGz@Bn zNe{Qylc5eVu(rKpWD~QkQ>3}@Yee>BDIH@yaZ3TYVeSB4o4evM##CEG>ggSTGKxc_ z(W{?czBKW>53h}mjDPss@zK$VOCxz)0%}~j+#y`*9sI=!a7of9na+rS5^9s+DX$3k zzW|azgn+L;$pX+C+3*taaZ-2z`mJ&vBl04b1i@aqf{V!!0%b0ktTF{56VEeAe9MIy zh?sg_Hlc(1DWsYUZ{o%QOAsfbP4aS3S%xw;cyu5|$i8;c)d<4iBs=ruz(F13ksQg3ra2QWaH@+;Qg+##RO>L=CV5wGI5XUEh7Z zz5PAe`u@|{*u@VgMm`<=aAI@>|8e>H_qV$Hdj^hQ-L&~2Q8B%x%i61KF#t)~k>(8B z=LPe`o(fSh?DEJ28O~&UuGlFc?WbVBvfLTa5m_$f1JGYx>Qko_;%fSVg0{s48zwm} zav}NB63l5|1!U&x4}YK&O5`TEhaeR52({bZ1Bk0Y%2`ICG0aSvJOO3jjd8HwqP+C} zDwO~^dfMUwY@8UaIG5a7Md>6Xm5cdeRCgb%@}{Y2TpVVMbtmwCUZ;z2hj*VzA0!r* zY*YT^ShMX7jbm$s)e2-Rq{Xr@R3rp!oprHm_1}$pX+dtT*>bkc4D}14=yDEy_(ouEAw`9k065DB4+|`}?$t8pg zx)z8=BChl&3kyd9VGA4a6j-z|0vcZnfib#>2Ej_U7pL z@yU+EMt@%Foa+(k^`S!_*KCv#T*3j%3f4dSzT z#KM_Xx=2RL_Gejs!=aJFINVeACZ2Hsx#)!;^&c~BbC=cp$trth(ri*hvITHrKDg z>9KBvg2U#rH3o6yvpCjS%u%%)dGY8MBn4GeWS}+BJ($EON5`i=0vb}c8T*uU48Qlr zE(`?tWb<>f4!)(D6myroAss6NAqJHOMb{9$-uxT`WeO*xc-a&$r!JhXaY&N1A~cbd&gJ5VpYF@WF9pyr?pQU_Q^CxLDH3H7(0F^(nVDt5nHM zmZziv83lW#kV{LU+On%SG04XQxxuBeOjYQKa>agE0@?wCNTYf}#jb8d4s1NqqWs{j z8S^ipCp$HfQrM#Rae%46aXo+;AcErI3r}QCHk)6IUu?66<)^RDD(18Kns(Cno+9A* z^(3I=$kf!xv5q4rM^21=c=FVEND%f5j2wRNt)0&w$EU6%Q)cBO?PzN}cmIG3Vl^w$ zhMf5?p1Ud^EIm?~G=$=27ARNfU_n^DoXW%YgNcmTpB(XrtD5%(C>5zP#}n#su?peO zm#a4x#0nEUR^G^<$Ar8}-z+C4;925Db3!9tc49PGI^qk(Np088m<0RwvWsGe4f$EI z*G|6JqLz>WY^+h1%-s>)D7FjA6dL@d4uA$A^)>6kl)#IBKs}YNky2W!Bs0Levk$|F zUC(v5Ze5m&mi*8Kf!GkFKKt~AYyrAhV}WA)xTo`CeBhYR-28Naz3XH0C_ ziVPzKb&EKEl z6L?XaphyZ*iB%T!Yt#qM4pU(JUhEyJ#6*%0i0QDL~&#gcCYq{j>!9d{~8G%6ikW~GpPZB2Z{ zmsQd<0(WMpmhKws5BFh$M-(U46(l}kLnGbue0iQb8TNvz#-UCcN)irp;K&))wOpj& z6p}W6h6OBZNc#Q2$3$6y%mO?q5@Yz^`*i&YbKF-S3+9{oKVm%JQA}HcH0_z$sqDGK z?elB;W^6{b_PlW5?K-Yr>1E&^Bjy*GQ<;d94^e*K9QY^O#s8?XaR3eDj zuRtXMCiTfZ2rSV5hXK`)+kjj{?6$YP6DQZnBCh6q&uRky(o2bGENfNB4X4IBMvjd9 z6$QkR(UHl0wnVM}{@yMu(JbqEn=1rsVR+E@xQzI%STHmirjz+ug=!JDExVS#nTP)Q z=4yS^OGCxpejzsgC5&Dy-@D#AM!>PuSlG zY+!ZV+ys_qZ#;uRmDXBP(ernpO`Znrr4(DGVxv;}EFaZ}%nDQ>#EMR)h@903!>X5V z@k2#JQPI;?kuw(mAu%5ZK{%t3=W~JQOSY5gtYLU`LIL$JfXS7=iumuhpcDT)i09&g~Ge+8O z(Y7Q188jm$i7bg|ScZ>mLN=b}Gy zM9j*%glVq`j18M9$GGP01B4Xv0PN;e6Q}H}H>K#pY#;gWYVdK{ra!&R>pEEz8TbSC zRXO1L@LYBBz;m!f$Ll)H3m0baT!su0orBoc1s~_df|Ha$kR=+{*khtJhzQm9?C30W z69s75qT%Cnm~3~KH*^1|Y%NeNy0D;`qfQ;$|ElupN6U!0wAU((1i z{wnUwB5gKeuFeQ-Lj!v~M>f5_oC_tDQk25m=-xX>l{V-;#IBbtp~xOle+M=hd%r4e zR!SX74t7Dl4dsJqc102^Ms0j0=)VAu7;O&=S5REw5stAS z3B}Nrn_EvbgJe^BItDA~lP<_I$(~r@kg|?`E@-xeSoeiY#b8Psni?HB$tdN-TnUG?(>g=y?^U z!;c+-H+$zvW=&Rli2L1KTzyq^UL!I_V(g78EaGK20I6vC=wPbsU0*O`prC=2er3a% zp`%6eAL}&FAokDW6Rl zj(B31lUI?gU});BAN2`&WB!9XxvwcO2hZp%`j=nE}36A~kB9&IMlCgrLoE0EdL5ejLx%|o zxQdDzfpCzWoxt9qw!_9cD{oM9)Lc>r>21$I#>7|WbT!W@;K@dBw$(5W80|b~>oj=V z;#%R*z(7F~N&ik#3y5w$!?5ri<=Fmo38qiQ3o9r5f_A5>9LDz5B@!057ZEPsc%01r zuDwpw3?w6T%pUAh+1Sm!Q4A1IZYWweOZ6FuU7 zrlt0wL0Cs+6wdhrYPQzzWNJjfyG*op@5ZFnKRB4!l9OYHJC2SY8lM_HHRUS^`)o}9 z`1US12sUaI^>Z-qjF!aqzKd{y4fO<6zD#@$hn@p>??S7{`mVFh<*$_44ff4|%z$oX z_VPi$pjb2%Kt1QNz;h3_XH$%QBxYz-dAXPDd#wJSD*)G1Bc7!5) zz^F|vKz5_x4*mQIIxJZ=QEOazoJ?9g#23tAnNV9$SF@cSvJyocpdS^oo#cjGgH#@( z-!1fwI1OM9VcQh+_ReIRo7Vkx>nx6TFWU@{Rx1j6z>$n zOV0_>^w*|o+(ehK&B9CA%gks7)@mNXT~=k>C_n5FH|E4ffm?vL_XF8Kgi$03qaVG^ zf15fr`q9K#$6*xrjt3vVPriNdkGp6Jr25(YyEqb_TflDHdvgtE5xg+W;-&dlESAKA z&FOlRz>2F7y4h%fE{EcGZ9N)5Zl^T@_av1k-?GMjyCBu<$U-vA3mZQMmGvu&xy$m8 zX5R_WL3zYns3|~i}Zm!EaJxKM_V^!d3R)Jov@;pDfklFl1Jk7hKq9`J( zU_YpEYX{Ls*{EQzfP@khczho(V{V)Tr>bTYzg1}Xfwf`GUK^x^d=1UEV*YtPQ3C^} z_VkVIh+c@%1O;%Ygp37d`tHmQ#WNt-EO9Yq61u7m1?(v1d%9@Z>pgvW-lgSML!eeX zNm#=7vpQW$lE;C|jnvva2DN2xYHglDfSYt?d9lqK+tnkv03xz+-K;V?;M8WgS!H-j z1l6dU*%bc!1Vn?{^+7TLe`ZS+0d>xWyE!MMvC`Y`8+)pR9D3~8TpX!Q^^XS0>n3Gu zMKKby3wyQ)=(-yih>o3+_Q}bqu@GkOlRxij?S|re6(;W)uvRg?AuF*4{$K-7XAYDO z1a64A=W!*^E%c5HeJjXH^Bf(-e*Wk$Yb;W_<#3S)!o!z{Y z@#1i!ZbC%>ut_mI;Oz)-FU+=Id+vyMOEd7mT3ttFk$`izeplCg&oPFCemKHWsF!tWI5 z4XMv9iz-%RyX}Pt-wq34%Ox9_ICjW)0DyFPMpRB=!!61GX=uIEcIzFyEv3J?wUM*F2nQz+d<{mI&Y8hr$0%5^B${!}EP57<= z3n{ZlYC!1Ogmg1B2spxQI*<@h4>LX++QrFC*261;L2Ui6PzG|=nZf97Bl4_=F-)pED_Io-V4-x4q~9wEg2Ng?RFcDNWk{z&(H-V>5MoY7EbgE2S04U~A5V-xq}O`oA`R*_oXNs;j7cEER`j1;}<#Q3o>h*Q=?a* zM7H^LF^X0gz5 z&C5?C^Tdi*VMK0&qNk-iHiVBa(+y#u(8O7kF9l_}_&MRV>)Jl%Mw_&5ybW@;d-iK< zpespojZkuk?2^DM*{3{r7>b)a`(gB&AzcJj55oqead=>JPHJln&Ucox(U3~Q62tNM zHq1>3Hx*hv_2JANh^S`qFpzImflMSf-)QQ!V=NYW*j&*NP==dQ%xM)X*O^jLXcc~u z!XgrNDMoti`Gd{op}&AXyOy@>9yJiwA4#W9;!BMA1EKaoIGN4?6mH;x)L*PoXT8J; z+zJF6zZ4>8{dCS+6jZKcP1teki0`?7hw45js9_yFDa(9j9_L7Ir`|U!FV(K)mSQ*X z(erS9z89vk$2Ruj+1oi_+r;T|xs_IAeG%Xv(Rkgv4tyYZRhc5d#xy{HV)d*K2Dtb7+WpFFW*#f~u+~Y+0#3p39nH-tjkp5yg8LFxBY+k5y z%bMG3&aI0A$&Pd|OSDhrU&Ck|CX?z%CBJ;FuDOk8#jy6rxO4y1;OwZ|yOypN(P^;c zvFkCAiaC(Psj1wUQj~He#20K$gCG35QP_Da{ zDYi3?)iQ){V{IM(&no&*JjU%sR>2>19x_%*^B4{l4k$`+C;=fEndVEor4< zOOYp}6FOUpy+X5-%VnMJR!h!N?-HQ@m?;jzpZ2Ft829iUi*mw51Xmev@WA2NQo9}4 z;xrVnY(s!)F(~q(A=>Mj&~2PX+IDc!9i?Of3iC%v-6R!h6pYzenR)m=R|%Gj`ec`1YKu4Ox0dxFoY5UHtG%&)q{-Nfb*^q4$~Qm;ZeIfGgI0VllXg zO{6?O5L|ztds3&aztB2AORRvbf?k$CD2~&-a$ZsXQEe9&T}Q=wY`JOI0E)CJ<}*%i z{w?H`JL*8Y)lFRI^xz9m#2olc{*!|%ZY+tR_yP2kEq0W#%9)T`<&Ny1;L@{iNJfUU zB`~ci-xC=Tdm{hXR)p7RC9QQYDIh6efa~-wW>+r+RD+pQadPkHDYoQ8!o{@{{y>8F zzhu2SDzW^Q$W*6hS;SihU*#W0fixO--IsFvcv*T)FQm}{P3n}9R139}LtNq3=`4-Z z-r&Gl!@W%AI9Z)onecw>Q<^mj8^=sTTff6|A;P8^(XSV0CIK~%vpgbXyuTql)&J1;o?}1{uhgP%4mSR@P`_C0M&n-nGFJ!QN zt_Y`>z}UBVKAJWnHXQ5bpoTN$3g0T)6VAx{ZoJe-&d$+CAYRdp|))hvA0X%-QMD~HE;DUDeJ1X#ww=i z4w7ij-h|aqvlA2T$FX_VaforQEv>CEP3KJ_*~;Qe8FX6ZTO)O^L8My?w*g?} z=Pizm!yalk!FkGyO(lfLc(sfvxzsiO-{Z{e#m3ynhl%PZ;2L6A!fI+GPDT!n%X`nl z)jk^XIq60EFZM`0<}7K;$@E$9GP^r}%r)B5LUp?}IEf9^&_@tc(78=G+BaQk#M2YgDQ;_9SWj@@9^J|-i5XhGqN6>SJ9=A?Os(nEfg4Mj{WgT5Z7aL+jXbDYwEox#_!%rI#?;5; zBZl=vPN2f|oMYzw)a#7;a4ZS!EEf!0LY5*t8Yo&CQOzGsk5Ob>rz zgpa!@tuR{KY$O_n_wxU={rB(ys9<%tT&;?Pdk9n z7pwJN1zO_kfz$4FPb?rt4?878`a=OrC|@*iU07;Yf};Pfa@`*}K>Ake5YcJ>=fjAQ zanTh2w>LigMnad?+|Ea1H)SsSL@1E@m~2<62E`{-fswQ3X?v~j_?e7K>HsmaF3-@=OA#2rS{mQD>a1XB5IDq*~DucXDfKDKbMA6iF*^; zz-C`(dhF7F&1hAa|pHd@I%TN}~q%?3E; zHP%=dO??CvsSHS*uyEAzut;uM|$xb4yFKy@gHQ*-0X}shZI#{$sAep zqN~eqO+vuTpJ0lsFKl2nGRYDgcL0g`b>z`E_brrm6gterZoS02eE$Jnj^4_HMAMN}Aa^*h~tM z_mSOk%qrZtg|PzcGCj`Hmi;K-rSCnjgAr%cvb;JJO8pa`mX1}A*LtdhXPV*7Tx40f zmc4OFcrOQB?p+aJ;hb{~I?B47PWqRRo`yvrz>VZ%^iYISx1KXB3x8H(YAo-M=L8tYwL;9H&VJMJ#7 z2sS?H{kX0!uCg{?`qD08&9o+*F}nvo12j**6*+P5W2>;8;I!b)pOJzD(1v_Mm|lD6 zgQ-0gc#DxcdiyQ#-1OnZ)HO$pg!Q)3pSG27AZN#$qe1zQULV81Ym;94E_f^y5(n8q zruNg8wJ0~}8PPFRTXJO!G}e+SGeradX*0`bUGt_?7RDOp(`-EHRTU3Y;QCdeq^0#d zw!ya0>%!&K@v6vms-YB}Ic>;XFsX33Ze+^zHP34Ust2YUStJj7WBs6Nch2 z@v#WfFrV*__G%_$$hrBrRy?jImJs0G@T?o)JD*pBgnb~i?2fu%ad8I;HaMPMrhArN zx#AHz9PE#pp5))!wtIpq=-$?GAhdki!7Jx`H@6WkgdaM#gZi2|J}z9mRS5_z@o*a> z;qUQrJ%tHyum`j5CWzLfE-a7kqaVGzKA-8gAe%NWFR)L}d$N3eoLk?m&wK0eKboE1 zFu`A3KN^ah+@Cs{_}wq3y3T_exOoPB^J2v1qTf9&J&irR>+AAv->)s|A72W32?`4> zz{Cs1$;o53a0sH)QIlsW-NaxY?CJc6`sBMjSptXF;R_s99A zzC5w7>+t$~8tg4ce7^Df&Yg^=n+%LvNaJo9(|-HBFkY76S3#hb3q2t7chLx+RfdLW zSNwLoipiu>0QAdhf6>a|^jo1@3O49{-5kCj0TC)#%g!2oo>qT%uA|${CZ^QKuIIxl zT1|ybX)6z*4k8L{?5;uw4-KTUQMr;USeO22>sUqiz11*K(K#@FamD@2>O!e#ZkHXO zCaNfS=PB2)A;&0YM?fce<147IGS=upam+2oDrM9zt0yR(*JN?7p|;|W6EPJ&W$bBm2zhDJ z{PjO=b=L}eT6>&RQgAF&Yyxuv@m|h*zO&10WlpGO$?^At=;ArCE}n4DV{@pmsri^z zwmw=`ZqZSRRx^Qcd`DWDM3;_xIKWtP5a3S>dkL#4t&@fE3)`T+Yt8UV22+1R*yJ%1 zAAW8Z890B-n{R033)*@=TnS+;!M`;)ai$ZLc+)k$#6jKmlSmr=Nub?Zp+{o?mk4BG zF&wn3@e88~vx;#*o*6^F``2=-^qonx(EI>Ro=j2*00q88j5wvAb-#*$&}2aBM{jzV z8>-@f51QiQ`M?n1RVQX_ao>!mz78SeP?XJ+>66(D!t;6_Qt|}f`>RNta8+?Nr1Z!c zJiLCNzBi;psHnoDaY3>gT|g@2+%D?l88UYO9K9t!>SKHK@?b9>OgbWp3w8F05Rv!E z?;+lIkV`i=1m}A=xOT2k<6^t5zMh%PT%uzFf1+(TKY#8G>9snSV=7$SNG^Po01}EO z?;^-y$tXe9FjioYOc?;(s!J?QQ3bF-mq;y?XerCU)mo|9RIbhVPrl|XsJE68l}en| zWrh{``N7NT`!ht_0w391 zS-CNiUt1&wA{jMdj90)gWQ1%1XNa=dkUrCnW5p}X;R;g-#r7^sGz;;>$9pyM`~7JB zMeh!bu@OSP6`2;B6A;8gB$W|LLlwAh@8=kS8)IGSRb6z7 zR{O0ZFV83F9VE}BY&KW52kvsAbn9x%8dsn3eP`gD`Ia<3s|SB&rnsD@ngV)p2N`<= zSd?(}3W^!OlWDZ!X)(xq&;6U-_+0qRhDSK^NQ+@Q|gqTe;09VGYBcl={a}WSGNJxqSvmR!tNaE5VAjo$z5Bdgr4`h^}c_t9U-0+62rW{O=Lv&R?5exF@}iD1Kb)F{W!2+__z zeV0$@R!G+_Azb^s8YD&PE3TwY@4l`ja*N?+;}cY(??Fe|7{5p4Xm&kC7LFvyfdl;* z$Hgyq3WhX-FQLVn-4(ADX`LUO;6c_q13n8gCBDm2T@7WTfFiZVM>B~MMC2+7a=$w~ ztEdrz^7&`c98w`mK@g7%2n(mozxf3Wu3nq_2M>1L6S_(p@m+9DyQ0 z#mqmU%grdR(`QF4JKZvp)@guBc{c&h6v5iAD`so0QiLNm9c92n(pH!UB&aAGG>GU9 z^d}uh&w>pm4IHH&`eN-(x$&*ei@*+nRw!`KUHuyga3W?J1vn9=$XW*M^odZniNVcG zaAlX53$T@Xfb6~c90$eZTm(c+JsolLz1SHI+%=PzZ9^*StFElyk3D=0B$Kf`pT-`i@K;-C9F z2P0Cu7kY2Njmtzb>id{R?&+V>ZEYx@C$)I(VgDSKgflHFtq8Q@ZZ+cChSq0id}Qu| zXgU+Vr>OA75R$S=y+jBQG;(9P^t9BgQlLs2bBXjy)q3@q83!OI$BZH^^tBADDQfhQ zfs}NHK&tDsroKD^E6t`!>=GcZt{P(e5J_zX!XR zHF-(g!R6y}%7khGXyUMe_{C3PvQyl^g$9)uZi>zcTnZ;=q%HFbIKlMrOoTm9P?bIo zXK}%85&}Pl(JwivHbT#vS&YZ@@7{>L5(M+bYEyqr^eOy!e+~Nq z&PuNGa@kZyL06 zP1H2~*47{WqKW~Ne4Gcr%R>{RM}s*_nlR20R7R0e^z!YE8BM`v095>yd_U~$sZ2i? zGewp&;D#w(^hZCWs_k+T?!3iYD{JaWBw07r0ZI-8^H#xPW_@eRT|%stPM&+PsxgJ? z(uq_uCrl!M9fg}!XyruOI4y8bFZ9P8Tk?5Jxvgqi47RB=clkLtq2%Mq!62fIqZ9Gi zrB}b@##b)ukQQVordGxASdl zoVl^QlyL?IjTyoN^)D>%5TTULno0zOn5c&&VJv;-s1ESQC=B7O(0U=29F8+^^Xq~k z5qx8};nYnmPF*i}G%aIX|DS&9A^L0N?sL={{R#*+y^8Qhh(aJFpL`Rq*jeLPd(G|Y z&~%yOl5&sOqtuaCV}sgxqN$3BeTmYP-wnpCzuU2v3jLm=8_HibM1jc^hXN2Yf^+46 zL@Z&b0t}KEo_i%pl^w~-rdMmXoSZg)hkEC}pHFUTWSS*HE``}r`7T_A*bxPvcAAC7 z*>HHkpE%;7Jpwd#UvYyh+`wtT2mI4kU?i29Ts}?Mf?^f;)W5Pl5^GdXb>!j}nr27k zsU2NlTo4ki7w~f|c2sEt`J;epAolROH*gS++r3(WzmWqmn>QL zwW|F>t&mb;_W+T|SJhe);=))a<>rp07SHb98r<~NXf5{Yo`s=X?nuy-&6sSCk~a+nbf1puWk#HEgEpcO&UI__VpOj4YP|;o#WH`c zR19OeOx=yzE>i# z#5(f`Ia8IN()U|h9+sHVZ2(6zg?l)|Vr+$1SWM;RNLjwOjQ4v#HRiYT1oJ!Rr?tbSxpYbx#TWVyf1HXp8vX;QonP|1? zDGgWI@@u8p^&dym^4hU*n+OwGn(tVwO0mHhOIJ;#Hs99fHp})xf6MvCl7PO5eT!HX z;wUCWQ;BL}DP-wn!ZDK?P6MjuiLUKLb(cCrj?}WyGESEi^^YydIhuS;e}f}>u1lvk zg3-7$wm$IpZvuz|J-H@7=@J!f*b;NRSH%ZlV;*B+9^p|f9U)1szDc!HrVVf7)dxGhA5q zuZ=h*>}i+hiklUGv-@*wVlxue%7HGHj7F08CI5<&v^4G49;~tua%wX(7tiY_Azn*o z?)D1&M;VeRqoY^?78sBkWt=)$qjYbKE~!dYLxX-r=qNjS#Fb6Gc(W)W0DKoR#(5-t zhIg{}yPCn*0!!v$!b_lbk_W9)d{1!Q)`m%IRd)q~c;mc{>uxduM_dC0_i-i6HmH@L zqOriSG9(K!J8B74ZVj`<@w9C99ar|$Pxg*n2BPD(PWrIvHzKx?)K4Z)giXJ%a$Mc( z&Y2b4boozpV@Pn0gbFK=)NOH%Y_2ay%QIaWy$mG|E^R*UjyF%vhY@|@#)U)C9qha> zr^k6=pB9#e-Y?5IVSaNnlqu>zkZWp92kTudl5Tiu<40O=Xd)ALACfq47&EH;d2Sf2u|nl$sZc?&zUKPdwHoY$0vXTfl(mHt*9KEV zCDkdf8TuPApckc<{V?&z^-o_HMd7>x!jmQ2gi@AGxHU* zH%JJr(`2>QWI3B>`lXFwa_7su4nbzMgpQT=+cn@{g|7Kek4s5xzOCK%zL~y}{!h;Weo~T*ng(jA4HhwlJv^K>}+gGn`)0yRR@>>3$ zmeqpc^K(eQ!9Y6}n+sCA7ZV|-(Yk%ge!`q8S#nhT$sp!Ja4`w*Q*5&cDIAI^qYL4Z z#3iH8Tnke|x{cWei@UQRDL*Tfp}KFO4F1I#g^d)^Bc1ab|52lc0^NIgT!kVbyvrpLm-nw1KP&uwj}4=od|ujx~`s* zQz}2d)c+XzkH)|AGnSi$S zG;ta=n?k=mxMbU)N2y@fW08ncwDHn^LohY$seWp1wnVl}#g@X8DGBTvL;3k>a|V1r z-a;BzvZ;sphj?^@1p1(jg0X$>ef0C4aen1zB1auBh(1$Rr6Maz86s4B5%;}{sU0W7 zaxmL9v-v=srzyfgD&zYI^v)KSXS*`ZK?e_^lZGk>^)t5s?FGuhdEc3xmAbx7rh90f zeNh-~n}lI_siGL!RL?0h9CajN$8ugIZ=;>If2;O-eDEF>^SgYf6kWn6Z8md%JO+BR z@_KrY7tMMgbhXPf?9m^+k~w@>I(Sh?+KZWD^+>7o&8FZb9qZ!-WHmHt{hS?B2T?BL zNn-h3Uj>@XfqitlZ_AM73Q7>!8q)a^&VfF|hA(<@Dl=fKjMFP~r*)Vn67M#-(2oa- zr_JLIdG#sw!weG`^vEs4vJpWy{;A}!9N%5**gN=OR4XK0N+961^f!PCr&V+pypRgi zMG#_lb=efTaZUJL%V{85(74D+h6X#4T1LmoCd;&NgFQ6aiKzuu3jFy=$xV!f@Brj<U z5Oo{D`;Im>OKHvHnP1oJsJeIFEfZx;2obAM*7xxH(59U(iO98){(9lSFcH3(j>a+D zD|CO+UpQw?Dfu2mb#Y3{8KKeGLvkBVCZ!JF$*K*_@zA|LEVkWS+nwLS<_yD#eAvL# zQ!l>Zm{#Ol$9Xwi>w&rYB2%Y*Z!1bcR9G))UH5crQq4!{wHU2DmE|63oH%f%F^^&ac*anJ}3BK#b|(&(<%9OIgqZ?BSjB?^dc?@I$~`6vC|-wR<00|o|{6}@TFmhP~D5Eops4RK77LWvf>gZ#LL5j*n{1VQt5H&Fha z@LL20lMRj4>YG-6v&{gks#8Winre=WY2f>~UXXM(=^w9sPAnj3q|!{nA=^oYu!vmT_EfzEN%9*uopT6N{Bm09?OCwkytD;Z7@o+*k7_J33qa`oLb zqVZ^RI@ro8nzM}&2};rAO4MQp+0+mP^Y&Lxs&rDUJSIc^ksz(?pmz)cuhWlQiSk;tMi$^L3x1wUBVGw3}L9HS>k6QM_ zE4%g{?C)QfX=YAp=Dx1Z4&U>E#PHQ6dwEBbdF$DWxLgo{ifA(R| zE!$7?$K$~uHil;kMKfkyB|^iJZ|h6rK(S83huIk~h=k65?8Z zjJKrl$p~@@MqRQEiuFth{pS1oTrQDGCJK6gd0`+_%v_pK!I%`9L#;e)E1a7bf)Ql2 zYo2ea*us8?HHUDjMX?ez?Yq!yISI#y)QJJ$2=1ofDR3-EkxbQrsBMMoB&9oK8xy6) zqtPR>&Ix+~`wbDtyKwzxFuL#r0Vi(^q0s=L6(cX3?w2pG>Xf`Mu?n#;S!=LoGnYig zacwfEIyFgDPzXl>)>V~B$1gNj><&L@vE(E@B-^i2Jmam{z!kicL_ zfauV^H2+XV7F@R#(W3$8F(fl;T$}ZR0oHU7*o`&R{Q55!bB3_f1CovRV z<4oLXLLR*#df10@D&BXwg+X^OYKUQljg-{5e_&4X>>j02-b1H<6(Ba7E~^S#3Ip^Oo4cz_`$EA0*u@?E~G9%sBa+FGs?J5Io2f{KxBV1CRj#K9FNXfcgf+IGZ0HeBBL5m2|3C|=V{+pg{?t-LDwR)()HZr-Hf)Tn zCo<#xmK5kz?_Kw}T`QuckTGIv3%miQhmr(TxbsAQa)s0nVtHzA$6=KR)vA!PrQ}Uq zbN)jrs37hepK1JC2$?1f*)tdO%JoIa6jA_4nh`l&bbKNz(bZY7E~VjO?p*#%sp0wb z86+KVN#RlBA39iT2_5erTCM3c!4*GtgW=>`b2Td_dDVk97V~#*5xm`X&9!>6cNmqc zGIXu-EUGMZRQ1ga^L|pz=>Hz6gX2ex)Fa5_rV126F`fZl-E9jB-zas$6sFuE-?if&3IR_ItIm3OBR)8I(mtG%NL}U23;I4iedWRH3R{ zrjbd9h4(&dQWd>BE2x9B`DcnJerNS1VD-t!`baI`LI#xErw8BHvb3rP6C>Q?mKI@0 zZ(OahyapyDBC;i1%_;5*S(cvQ*(s9_!sTofs>(H(z1-Ya851I}FOqB;cizo=)F|d@ zBJw)5D8J@;bhW4>Y;ZR~@2Np{ZY1|=Q}W|0tp0c0QG~<)z(D>|pdSGMNd6gjZg!4V zPUa>i&QA1tjwaSl^#534{7aUe?yvPV9UZ4tHguoOly?NIDf3P9ogl&<+l8p~e2cXF zUCG+E~4e{wC z&FmBFF|#Jd9G`=1Ft)X60(W?{exOcjn>`a#ZZl-9j1jg&nf8)HwzzJz53a~pSn<8G zVrT7liD7z~P4W3^2yM#i@wRnF3tuzAsKLY~d2!Vo+82C1Ooh{I9y=wXq1Wi|=Huq( z_F>joYZb!8y2TzSZw{|!gOA_Y5! z-*3^YPCXww(M<4>l#Wkys73cMQEwayC~q+PNEojq6Gej|3++x+6GV!t{cFy858aHE z!j)u>;cMI{z*AS?z|E#;Y`6vERMH$|B@0|0ENJn}8ducYG*cUVg{RfuBDcen0*sYgH-m^Nl@WLrR-QlVB_v5N0Npz717|>7s zw(Qd?;SV`VLxvMX5>ST|C)lAwKRUCiO+U=G)0oS?eRcSBOH=~aIyzR*Y{fmN3x^$E zmgyssAPZ)eKNyNw*lW+OC{scr4*HQ(V>I;V_KMb~xxnN^!F*%<4_Pbq%^I?sT**W{ z>wNs8hhxsJYu(+bh0k`ZBlu2LRs21uV|q=xvsh9V0aAnqpOS zVkj&--ghVa!){`4qW4kUsog;sc`$y?+v+!Qm%St_;dMyjSMvi$z$0qIPV{9?SFm9) z^ApeJ#Cq6LUJAxjT+7 z)Cepon{#5^(o?J3?Vikm+A9s_oUbgqT92PWy%ESVn8H6yqsmeVIqvY>WX3XP2Qg3G zx@MDL#IeRy?GS6D;nW)pSWwLh;d;ar=E1AKa_ryt`bo{toE<{V&sdk%)+-1rLXgyl zaBZpMxiHKrLfkW$$o*82XP>6De7HRluwqxmGS8%gbs}wdDOticw=d_L)+fRN|6$Pe znHy!8Zjgg{ArVy@O2ayTErzRh6%6u2P>W*@X@Ky+w|f~3a}H{NVb@K}Ua!+@hqif* z=lsRPFxrw$H6_nGBkMg6G)5XP+ExAZn+6nhGFVdp8n{8Mi;%c0sj9~B=W`ql+yPh5NeAAb3;bDP zM-(s)U;2OvR_5>9BagWL{T)#jT|2gLnIBn9h?V*dLU#YB7mX-gXGEW9)pI&3`sFK zs*|WIRaS~*qE%-WGRO53(UiF07T|dF{Oo~EcJDXho@R)dJ0out0s_k64li8mnmQDH zYw~YE#G9zAxkMBXMpSEi5UNEG2jpfg#&+`KZl^?(R5wKzx(p#LE8uP$EQRV5ThAaq zV3B;aR)=z(11Qk=Dbq>1=kDUgp(5^#ZD}A01NQLj%W9h1s5CXBnT1K`m>Wzq=4ZgXh_Eu=+T5jqB@nSM@Tq>XhfF~j+nB?_R6PDE0Uz(D}lZnVG3W)$H<4(GmAw658!6H1T55h?gF1{SyL z!9cmiECz~p8ze{-K~h@P+VaJX4ouc~!P}3K6qfsErT_Lc%7sJ zI6E$0Jb*)3(!S*&>eMgBy};^fc?Xb(<&qt3HOoEd4W2ZpW`>A@jNX! zTC`X5ilD#OPL_9?_V%X>zdW5n^`<(zs?vR`@L+xXB8O#}=eD{}wkp#%k{f;>1eLMX zyD4W?y8iK0!9we$SQvBCP{H!g8%^HxAFw7gG{AAqFQb^HjtcWQ+>x8umsL zL0zK2dChs{3o|WMJ}Opw&rz>!1P3lWb@AoFZ^fsiZKrRm&dxWCUV$VCmecI6K5q%-mko@lA^cf9CCz~!@|{<>b7uUMCY(oRf5 zuTP&Nh;_l&K^Aj*-Mc4NMY^x;$~s1=>pPav`8&F7loj4jBOP%;3$?oO-X;Ia3WUra zVnNR+xBbn6Zb!nYBw;jj0)B-FRf_`M+>8!Qh!6~%jqbmT9}H~z#inWQy!hV_ABO<{ z^vy(uW=SojQSMi-AuhJXZsa9=KPOTfRnU#Qb>#v6Y6^`y57EE~n8ojJdx00<`D0*O zwPX{K`(XFcpzP8NdTL$-b_dnlo3$(DwG6Lq@iEx54?431oy$AM1fJL~*+!bnF2a6!j0-F=EJYYI4qhceDb zWVVI~0k4VZX|$^Kg}0b2;^%}g7JnZ&gLcI&wVo`(rh}($QFKct_?&J-4@d; zTjXyt7%oF({MZa?oL1Ng6f_l&EFk*Q-YULEH{@#uXNE&J{Q7fi5v^EMpCuWMA|e}I zkhgi&DLhQW#Fs3F?gaPOky^%1Ba1DpA$o(FyFM$+xrGj{2(xXhpnqWK-FVw3YIx=y zy@41XGO##Zh`ZpWFq38iw1<0i+i%FiAbcwluZiL$GJw0~3S@vkN)m)y%I1nhl#l+v zg`ds>E?xBVn;rFy^P6_KSyd zsUdypzKvWGee9j)W8pV-gghw+Eh2nF$T3j}y|qsCPt>uQchi0vA&5HN;dW_gVg$kt zCl%7DupxCwq5Z9|2>@4|#VZ|K3U62tS5j&2*s4nG60g{gQ&|{&EbF#TpD#;4w>dI7 z4Vx<_siS67{+(q}uQ6AfqhB988o;g;C4Q5j_W z*D(Fp160wR_bO!K%Mx;L%#`R#vMSy1q>v_PKirc7e5FRHaO*B(-?jshPZVqT?Q)bR+ z&>*tX*jKCuG}y9>Nobu*iHbqK=jftqSRG5bh)I+>mJxd?$k`F^+7Zr&NN5)WDSdfoo$Rfd8ah6+7iZMKD#Zpl>R3$$~0J_!22Am?V=kTO688!CbCTU%}?3M8&%H zN>mMAF(jI;HAm7)qqu%P3|pb8C?Qx+j4GlBD2<_fd85R%D66`K&H5$kM%8&PWSAe; zFH*sYGlWnt+YmOlG*&6CJpazaHT}mMO&P)UrhaLV7%ljg37y5T28_ zWL?&(U`p`&?}BJoE{LU|NrWphI9tsM{5=I|J_OdtD1MJhX>4MWm}m&M_q41HY7k46 zEvkU2i6)gtkMD{fuV5(P>KGZfg?XXlV>wF1^t7 zzf%#+TJ&5-C*E?KpjL+bIk3LG zi&SsPJUZInW~p#509d@JLCO__IG)6Wc(&hIlkR>8Raln`vyf13s~Lj8`DeKRhG-0o z?WIEF-JY$}gm31u0$bX*8F_7byBfW;)+OQoOYDN_Br&)A!k~>+s2&G|RQNe%(*Uxb zC|>o5i;oBv91tVX?w?Rz4WX4EWM4OPhS*|8Tli(0+Xs(|zm%Vm#WjU&Up)fbY}iz1 zoiSVYO$m9eQUDx2$)hQC2>SpcSFeU_fGXZED1@XPF1-OA+0Sc%c4y4*OAZP~cbt5{ zC1)3Hpy;}`-IY5$=~|NZ)U(C6zA{=1ZY@KL^}oAy{d=uC`B{_v%b1tXxou-Y1-Vq; z#rw`I>1)_#6_=%xH66oq8bN>M;0Lms_JL7`sd?mOtl^a`OrhY{Pc-GDwlgN>*!6)$ zd-}s@CJMzI08mEL!L^&E0fy#R5OW?qPvbBHYXX@oN z_`c1VSLIc!tocNR4T!xsy)fqV500~vZUQ}VHnNABs`#q4j(-3mkZRp zlK+sY8dSl!^rnoRx_9&KY61ldaIYzIYE1w^ixuvDrFUN)s&AxKe(m#IC&n{*q zoeS93ja0xdFLI2EFX#-{oGfL$1FimP+vh@x5tnEdLfJ~53=3+|?hDr*mAa^`r7ehL zpm3wBvE3adfJv;b5eFdY#Wd#h4|b}N*%XU3QdFKXF&{-#zYwde?aipPvEdbp zT@$3<*b#G-r=4jRk4IpO8dM76iq>FBJAizYU5UF^J)AnC#^=POh_%pApMjJfDq>RH ztTV^nDI;C!^gCygEuXosB{J@_*2u#KGGkG7qRkV+FU({M>KYSSuv*P}q@(n#Vkygw zVNuhvDhOV2@mrOPm5YD9r?*Q7t{);$|MKRbsE2X45u_&XrC`{{24$U;f78VwV#P*l zN&G-&3FV5N*%M+Hf==2oS-2IkN`e{^FCm48@NTIYmLU&fCbYx;&Shl@S~yYn%9^K8 za$Z<(^#Q52AXvFrXQG6er1m%vH+YLn28?3=sv&kpMoRT!t7yz%8RJ1ROVqH|qt+T! z%O!|#scoa^A1P1^)YcG10jx)S37%Y+9VE~X|27}hce)?kvEWr@L+`AW%ZEnBxSG@m zE=-L%;!x{nkHk>Mo?47CzX#<1`*A-F;?Iqm3zTU{PRMZn&0m*5i%xx3qizLs}6^s~p`mn-GQaWU&k*O_)C))<+ z&E;=KDwQ=bY#^aB&=rO}?gumeMi+8ouP$J4kJp%9+eLRgkV5o6h9+wNZjBmhRM^5& zubO;x$4{KvH&F^(?KRS&EkX8fS|u7GM{&ERz6BE^Mtu}a&4Ikg1a@kD072Proiw6? z`;3#xglM%wDx45rxbU(=Zs$_?4Wmgdpp5X?@97tBdE}i=yXnNVvnNs>u#Xm>KqG z^&$p$zyFpz;rb-m=2v6@<)ujab?`XT`!fsE~p6ddjBo#>7198LZj`6J8) z1)|IbeEt0Y?a4Y$R<4f$G2~HVjp&B2CZUwSHeLmI9xXAJGS|>-Wp0t!m1Z^e0sTzT zt~=f+k$bC<>{nL7{f77L0VhF~DjIK)OYI_<6iOiRbUV5dJ&ZGZT0aRY=TyDEYDJC! zj=i~dX4d|ig05C`O#{#=SunVxGf~Wb2P~Ri-E^rlhAs#+V5`QBmgndGFi8(S9JKt`lFci}8O2 z%m0ovn?_Z{`mbN8LH@ch{9B|M*xUdAf%dPHD?Ogy4uuhO(2caSZ^UcYN&xMLcV#L1 ze83M0$u^u#FV3=PpW4k#Wb$Ri>cCiieFFhQf$nZI%Y@76$BX$pAqdjO$Emy_e*VT~ zy>S~Q;qbw>sB?IwB>y=+cH5+yx#NTkahix??aFeu*cvjCM*7Q5l-(_ANOMAR<88x1 zsea3Tk9o3jRov-6Glf?Mm9c*qb~`DfLP@IV54K_v=A2{tfP401FI8}a7dfLX-}L6z zPDAN4Wq+dqNjoU0E}DlpOM4hPU(H}7r&HSCzNnMOU|^ST`J@m|LLEiw+fym`yjCQe zn{3BM$A;7*fp+1A8!5_Km0PHr>ak#!fBW9uD)FN6hd7Zade?_VNE|0mLcfN25$ zBK4@-6|?=nV*LpW06_Uy)Bewrmav_z^H+zcr{ZC6;-vFe@GD6FCqRQL1IgXj<}zQs zD9nEVw0xaD|72(W!O8sH@W_pXPWfvS-Y<3$;Xe#FzySbvYx=)P9z#1jtN*rFsggp2 zmoFpnFMu!4{%z8Q{)h2Dcl_@V|MehpMun10cbv z-(7*YqULF4|Joh^{5KRr1fc)p%DKMX6#IzZ1CIV*|(MV9@CGaFL>0B~~lur_h}Z~Kn3*V4Rl1ptEcfxd#{-=|Ue zf0~*bIGPwMJ3IbG_x?A8zq7;t!Z;iJ2g3goSNwM`{!aJ(3&?onA3*p+12m4y7={6G7We>Y#){5SLe=~)z{!MMh} z2WM_G4h|X4mW>+6EiEqVTU^#walE8&qj}K5+-y%A?nc&FoQ>f7>;L{gUV)0BT9b0F z?GlLVXpfh9t*5hC6DwZC8Hbq1e!x$8bD00-R{yDaR6sEPzQ+U%H!Sw=Hy$tGy=`!~ z%P2Vgsh+uP!~EI6eL933ro7O$meB*Qx&(L^5)|GXZOFS~Cj6w7x*?C4>zvxTbE(*DSmMm6K6Vs&avIQiTHQR#ga=?+-@O~g+)3j zJii^%S#=|rwhQD>|Eqqg>lxGBb1d{%5AsjU6&I5$4j${>nMe3&q%SZo) zUZs#iWb?fb@jEVVji@dWnFr*F!EC}&SV6eCXSkTA0XE72y4(r~SKC_O%!czI_WyN) z{r{LsznFUZZCUwpu5CA9QVX8X5wAOMZa*e%FD_NcpyGDr)LW8V|MlP5B_Ll3I$CLl|sxj`uscJ*A9?Ab2exrZBGA5$KCaN|+F zK)ZzIB#=8VmceX?ojT9c}H##nHjF(OQ+MrClM+4jtrJQ>3q>|QQoIRJXDe3ua z*P~6|pX?+_DRcDV{xfn!`(NXZ9ODjuAH;pfuoY2+GZeHn=jfK#$GQ3)>h#_OTZQpn zpxoo5-;CoLHFd1T$Z=u&vt$B%v~D? z(p&bfMM&JpG%d3Fw22_U5RN}pLjE)v30c-H_cl9Xx*N?;eKf5#$|*P~gm0gD^o(%) zsTbnnp^b0u7wtGUMd$tIycJy05K_l$_vF#TANph7&=U=R9+*hv1{sP7$h1!WYOhg&*=s3jL!yW+S3I;M9fV zFEdL=Zfh}ZneTM-#Vt179`DX|H|NlYtQ5*4$B0tislKO(dQIAUeV_ce(Rs((+FW?? zy#P&(vOL!)YyQ{v1NqX1c2yFL$-!}suJk!ayK@L5Co2+2ry{QAJI%UJo+30ml})Q2 z&(WRb(}))_?k-Dzu!We~Y+`rR{ZktsnAn}rA1#e3?Q~nPM$I(EXqVn&>EJ(?xJ!^d zM<`YNSo-}LOA9xb$4I2>nECs2Y59xy-_NLW*Z1DD9=&*>m|a}}*&qgR_wOR$bZXLt zdGLwkCLEmII9oOV?EWYPzQpm*5@7>S;Q)>N|NXD%cD+dj*S2f0>m?p9i>+>>6>5$+ zvt2LF>%iyA+~Fx}rk5(_hzbuH=YNzC(ip$@5S+Wt03|rxOUq>2#m1UNgZrdCq9K=1 z3n8zD*yEMU`vgHu;V1Rz!9h-bXsfB5{KdkfjH!o}bi&F#f>bnU4(W@=N{3Wci`(qh zvVbYO`qL-OcwWQRWZX`zlAwd8zPRN&^R~^x?6B|0+jf~7j2C6^)K6SF8$%g zGRVj?nwAk<@D`8`YUL-9dG&kWrkx6~c zUKucEGhTo=e`jDObAKci``*4Y?vF;8JXP7C>bbIw`56c`n|>*Y`+np_aRf&cy&tO4 zi|r>lFI-ETShL~AX=bBjmEsBEuyLbP3ALu@%H$d(PLaD%Xvn{Dzn#7z_&U`bx|xF@ zO`-PDc77UR=-JaShbNrIiRX5RZ;d-z`Zn|2?mRt%$`AtUE9d>AZ&ei9B@dOq3@f>v z=q?@2Auy2Ga#{$=Gux}?csErxZXYQWHBabeDrHelm?S6rXN=3`=8-o<3i8l(OWGS^J$2*4~xT z`ULZ1_7F8L-Ngw{&o-2YYHgASeTB&j#C;tK*X;psLv&g{+@)N&x##hT&Sy*=<3U;z zs?r(N`rE?QLMllQ$u|;dy?t9{l|sU2Sipw64TVTt1zooqfskMW(Iw_Lr}A zouN(NeDv0C96wLW?RA}wJLeeQ5OVIhS4zFtk9G&tv-u#-)w~VQjE*&=Uuh!V67RjU z%{aD3;H#vaw{+=G2( zu11pr!iP%wZcW|_ZhqRD!EzkhDn;bC=%gDvnV6yl@10wsDL^(DOnQ{Y9i|t-kr+br zu6_Jho;)O~HoF@AtoVM??gt&z{&B`Lv6^kf62_Qfnv^XUZ=k5&werLm)WJvYoc&<6 z{T2)H4l(q+JOmBbElhZksE{ z;TpwIri{^&5(wjNE{`ZXJ;;WN@lpIecBZASjL<%^wqg-i#%xZ#s_}8ev-e`r)nIY|IAP9 z1EpPzJbCpv90A_cf;X5ZJ~>)jj9?IEM-7B%p!E7wASP6)KDZEG?RF=nCYo)>49#G8 zia}2n4Gqiivm4EXp#?0%!3E6h3u!{sUYr%`{e0ZBYGz)US0C@I!s`v#N$usVYqp0D z+;G?nZg6l9Hc)dR>|(mN^2#+k7eY(7>miUHI}4%RgdcCPcx6wVNIdL8B^&o17S5>H zaN(i1F62!XP4gK|mR9F%*Ud6}YXC$-iM)u(V}3+;`MCMj`D5x_%o@zB44Ww#MOtsR zlj9ns(3E{sJK2gjy4}F)NOvptt*ox0R*a}vm(}9aqZaa(8 z=3Zf9H@?(38`)dzLtHH?+#GiY-W@(ew|HSos8leAjVy)FsIQo=%cP_qC)#VWlAgO{ zj;V?YQZd&XxKn#!1H6CvQ!tgF>zwE-jy!`Ny>q=804N_@jEmg8!=lLwkqwb?n|sN< zt%+@9$x+!@Ad1m0avPX;4Ah(_yUvJ;Jmv>wtC)+NdNCKNdn@nyZW5|`Vg8+Vmr4w& zpMurIq4&z%;Ig;s;ZX2SY6r`T&?eaR0>LyKxLJJ9rcnHtGXVTuLr%7%k)dZ;8!kOG zP*toq@VO}HS-|YO_@UPNQsjOpI$@*C-deDXTNl#Ws30oh&tuMPpYjZre?CS+pnu|Ng0!#JPg_%s*rv^1ABmM|WF9&&LD zGJKS5syS0kUxQVemO4==$wmK0i2gm|Ep1oC7%wI}OFYLLras26(7pAtjU%4Se(04j zM>d4U)o@Z+{Ll%t{;h|$9r8;yc%@kH;?oRfm=w@CtS{J_(D3%gI3WY|6YY#fKKqH7 zwniuM=QrN$W+=?1zUC5q<0jDqT5&DCE~ft4L)ToQZrtQ--aZw)`&fwzM`L~BZmwI0 zpH?V6;HnQIJ7d0uTv_fyI4+I2Y*(bfZRR#Ik?I120HWIi1N)D(Cj_Xqt4Y<1*yEYB z?mEC?S>7R0q~nlrEF&x{^T~x8ypg+Xkh%*s=ur`6Q6$$uHi@$6BG&*M{Mm43*np9t z|BRtKr50m~!X4?7`=W{|VIj{-)$X54Ml~EdBxG4E_fl6O?^5jzlWD4;^7<>Kh}8Gi z+|jmS@-6DeMK3r|-{Rbf?n+l@vK=xN44{a@chKawu&1f58jEJ5&c{zET5!EsBg(54 zmNmIp6?7J*mD+(+u~5sNb$z6P5x=KvabMw%0?FWv_p!=Rm3z!Fba-hUA3hXG9FY(W ze(>hDyg|K^|JIPUv==N8i}MV@$jknG{bV=tNs-T}E-+d9H>W&>7%R4O4M0!Iw0XVd z)@QPY1`*|LQsxf`kZDUU<4~S8o~R1yL?KRhHn146?Ucn05yFVyvUYQ?@@R_>q2dY> z+j}PERI*}JscbS_beQ~u%B-IB5l8wm$0&QPZTugm`z7!ac%mL=-njg1Ld)o#@5Jd% z%G@G7yIePU=^-h%D{GnP)Z||co$}w4P;?tz6J&|UJ?y-FQzyL&lnt9C<84|7zs`SgREHJ6`M?S4g2 z-tow_$M?~NLW7zcV>j3=h4+|@C(h9-cey-WnxR4UEjHA12mKluXT6$^?d-PMZjuVk z3|vaWBEcKD8?GX$y7`_da0^{XX{fq&EgF+Y$Xra%0NGXgrcL1 zU@t%59>5KgYc|~eK_zx_^iZJtVNG&!rqk)nd_%>$6^>1IJ|9xe(05(3N-YIFL|~51 zmEP_yV0uX`+vqE|B*g8!8sy(Wgc0Yr$d_q*+{vxqXBQZx_L4-VO~w8b-a!2Xl195q z)rMzHNSJ51>$Q>ZL%-%zmLv2s^7~Zp$j3i%j*uT(>|~ot9VH8K znY!V*YxE{`(e0tv((I-P$|bosrm~*gL-BEvD&$NzeRKgNzoYCvq$eCD7Aa(%y>tP1 zUEG=7tq(AX(U&6S3*N>{WxnN5xFb)Z=ohOnIjs`Q9CdQ@0)JFPM~QNRa+~PE5B;*+ zd0PcyP;yUwSf!Fp(;1?;T)rB-&DB$&x}y^zBf-59x^u+?%DT8}KVj|ot-vZWU6&R~Nc44pf*qM~GV@NepfDJLx?0gN$l}6?9u2#pf zu==sE^j{3noTH-DcHszNeV#n(_io~=Aptqpdw(EcgE;l_&Nq=Ml@v_Wd;%H#L_}oq zZo?Ko@+M{P=KiO3G108#{bW_cqXP{QZ@fyGV#42eDH@}*VA(~HRfEZubqH&K;x%u! z5W@n&^Y?-0p99Y?$}n&T5x~}6t6xJO6FODRlC&`CovJ=Ae&@L^U9E_T(Ic_R5T|Dm zuSMMBo~QvvIEbD;ac@sdtbi^zOhlBgm=q$3{lDZvPULa;GhK^vO9}FR<=K-u4&9-S z3?`z8dX^*?xHoj?h{$|4Rc6;FlW_qB7m%M#XbhL{`ktDE|)YpMv zyW&mx0hRc{E!*fVH|o|@wF7hr(5Z=F3Y{9fQ8?_xi=G%-Qlm#CzwBIAHLp~2)N!DnLAAt6n3PMjsF;$UL@l9wF7(CmXOMgi(` zrvCgiwaZX8vUPJG+0F;?o1x>oB3!j_!k*DCX6{KZl3<=rS0Mk!ZWhfqOsq=%WB{5j z!n+}4Km-gjZ5J~*6xGd^l$_Vwqv(ap&Z2F-FA- z>XJstUlt9G2CpVton4MtF3R1K*WX&ue@8$vvfF*MyIFm1w$TS5At2ls)*={3@jHpR z7Kt}9mDVKCowXtfx&@vp`w=&N2I!PB-yZ)6CXlsT%OS&lX4aY{oWj~^BP)FjVY9HP zNJkC&1l4AwiY{NEJhc3Z5q(U9MyYbYa@( z4QlWZcx>IJZ9MwrcGW>d9U{tFoqAZL$s)O{mQ}x? z!V5K3V|UqjA=N;zh$!=008w)E2#Bf$E1ANztbsrF%wPdy+kw&q%m~9R(&t^p+~YCHZ1~P<}ZN09XqE z(gGL=08oTWzwiC?AXlRz9{r?ZZhZv0SSg>(C!DqDspqYtr)u{M+@|WUlU;YGpr4j} z>z_x|4iFJ7r*Km}NVXs9el5d4WabhOF?MNK)CgAgpS2Mz>g@k&0jcwaz>1E86?HyZ zBRwsdRJ5loh-z=>l=}%l&`p*f0%N6KT4FUe7mqP4J=dJc&2*yh_MT^=l!aX59Jq@G z5~z6Vv7M-&7Co(Y%T8S)3W7OL`;zX7XH=vV1b!401aUh9K7lTzm?v`Cze8&EQ)yq}?%pKsN=20NV|Hbumm``Gt>X9KSo_@Z9gGqqnk0z=Djiv?@;^N2Zm zRBW_+2{Qz2op8tSX(Bk;jX+w~1l^^__L^dEqQsbCtXl(8)nPrm^ok|N2)o81{k&C9 zfUArEu2PI|PE@dIv%+-w8&gZ>V0BoqRG(oqYOVzDy8Dcl_v-`$7;3k#_wKz1t!~ga zOyhYwJr0ZQMePO%qVIuwBM@fFHLy4!Q|UxqGeZg!I+q*-voH1Id z5g)GI#3+^HD;qTCpXMWLF5Y!HP8}(on}|#fiy6nKikRu@JVCo7@#fJQQ<(Fo*E%)JAH^x%de0Zu9fg3fuucN4P+GK*pw`JDQi0^< zMY(2IW|cgtOAefq+ynbWlZq-lRSt#m4#*D5IC=-NfxG(j_>xJZeeZ(VP=_7#YL=Lx zykaAxRgAP~T+vWUbjB(7_dL~UHP_f#%=Czu;-c;~P(M}PtI{TgP7gx`A`ifHM$imT z>b%{r0a7+#-3HSfbRp%x)FKmhfjY_TokdvI)j}|habRaY#!nhO-nYaP87VZuGlV!M zhA$@0rP{^O6){Y2(vwg#OAJ3W!9MD(tAA$Wk%M$6U5}cdlx^N;)pGgNTgQ+SB5!3B z(X0iGggoqpdx1Q80ob7okSE*)K%VqdvT?SJhJrVSHKHWMQvP`|2h+Nls(q4K-$NWG zNNNQFD8Sr0xN804h_}FqFe!#+S-iaWD@=GynrYk$$XI+fr-}N#=9rdkW&;pU&uOZ^ zDVIHsgwmyZxd+wA0gOB|K$R5Nh%ba8DJg#3qptYA99+lQ30OR{3l-*xR`w-WR( zju>D#SMTlaSEEWm3@etDLZa;6&~OlvHSU>s#~IOPoz5zw1Fk(3*9j8hxWt3R9u@Vz zc19xoQ(0w>QsK%d9WBcWOi7VB>QU}N6(T>=i#d7nLiz=%oZ1ra@O|SocdcKrTHa0Z zj1a6z>)?pn6Q6a~y}UY$WH$j_(LUGuOx-q5a7hM*RN6b7v^>?#OHnH-9PgS(BY0PU zo_nAkFb}w5BD%_h+*Z0jizLNtD9zA}IrY6j@`$@W{UI6Q>G^sXi%BTp9X_sIT#6(D zY!r)wC-hGfHRtkQ?TPP>WYeDI9zYe!AhQCOUe<~xxwKUWA;9R1U~~+rVxX2cR`l!A ziwC0Lph)84dWw$qU2~ca7$u8oJinloYPAn-rQ&Tln1b@&&3w@6A;CZ(*Al`9#`~ZL z1g#{<_p0#ATylts51o32t1K$XJzztwq@~x*J&;I{w#>RJ$f}7b2q0XrlLQ+4)vkvnmI{gem)S9`Rq8ms>AgsQHnU?CpQEK z%4{~-QKNkVavyQe@uu%F$HaE^;+f79i(pcaYuz(TEItKREX*Swv1zZ$tmLIR^=|Pw z$!>pjz5Ng}G+>C2H2~ij4+VU~5b%wwKvg@;GoS%x{07W;MkTd_&r7$lJ2dW!JKKGQ z`eXHojdBiW!xVR>WzSp|Q-oCPYf!j0f%XEdK1FfJ`2}n1O;5iXG%)nQ7;VordG#TU z07?OX(hs2A0#HtlZ(!rBrw_~eFb)`D^JhkAh}8?9ovw~QP2LL~`3V`;(8Dag_}11q-10UVJ`8Atj3%3Wl>K~#hJpI&2i!%g5`Z4jb@ zXJF|oW{DOVNH=90-0SYw+yib@8}gzmGV4ZR!6vtDrsIpSoR8M3`?BPm03*<24#!8k z*z00<$PR0C`RKC`?`Ra9=nuS49YA4A;KhgV?4zyr@ngq%l_p{zPZeP*#-q5kbFFZx4x8y^> z1bzaT*2SPwM}tow=TfAP4m~YKz#a%gFv9-voW5(1{f7?A!H zK>9{!nWND|EBm&=p|3;-odD&*oic9;W%f{^0Eznj+Y{!_dKRYH(@8 z90a#2hAD2ceGsKL2VN_eU@C$HB)JCaNo5>uJ`=Mde2!Z)08sTu;#NttFd!F4yxdV6 zq`3gJjI-~qW5}wf&d^s6lQYvX4+lK1C@=_Wfnw=Nv1H*a>E>O*ouo8fTghY`FSxUD z%5V)_2a=x)D9+5GQ_pafv^>Rl{<;M`c3k3(cS8KdxKzlQ+4q0 z`i^tbDE!^iAXhq?uq>#D8upz(TOk4l92t^jTlZPNmb_>ES}oNez=lSkq<5A?VAp{526Zs4Gje?G-2)T+)&S-$;E+5y{t?j^ zoT&lkck=2pA%Igdfk{bA7ouMy%GVV-1v6I}o~7AbydAk{Pq=y4W=&#wwCE6-ma3WL znfri%7)t6>;0;scyUy69NS|!u(i9B>6BKE~IS=63`tu1(UT}bU5BN?*t0xt)+{MyW zLQz$TUQOYA=oI!|S|A&2_~YA$CBVCzqT9FA$t%NLA%z8UeHy!66J`c66Ind*HMYPs z3HJ6r$4nP~*ZXBgopzqZk`@K{`Vbzc;!VXi9&yY>n=hPGMnZq%ZjR{U9!Pm+9M6VK zx}vi-)tN>5KzR$(snK8#^{K(_@=gJgGT8fAX9Me%tQSREE*g4>VaLR(v=YONiWpYP z^!XZFf8Jropz<*qKE>LK<89RkZG!S;n#=qAoMt<(0+RtiEbEEq*+9EX9)0^NU$xH} z`U)qyViEKdY36`><}ffIh5`F?Ju3cyMDHvmRvH(h{n@DBfqjfpY~6Mte`M2fef>C~ zV$@L2$kTAJlL5b^VtjsO{w_X6XvOg+-bo$%3rkxlHlKslYn+)67r}tca?L9B1`czS zq72fFT)Bk)dJt?oP3Y7jFptE5c@(HygNSFh6^f5F+-Tz3dY1Gx{21S^0a(=G9(^Fn z<~gG!OF5eiu;PnE7Fyx4=!(^SSz$5fu;?#BMk8wC`lt{y+Fhujx4-*&D;Gts=n=hX znM>&@GZ(s^R7EcZmrImEWS`vc+<;ig21IZQ`eK~A-av4m4v1? z>)rDpeGmrVf7^9)s`JK2)?wJ&R)`kk3hMT<3^o~L%@B{_Qf*M2IOaX%c|q#Xu9?GN z1@|>Pq8|_;TT-@5)k%YQ@hP;NqxVFdp;s;eXjjz_DBzAp(&*sm%_$h|Xjs2KEE55KcQ7)0s3E50Rq`#+9nUng?T5{^Mb`V$V6$D zcnJF0y(Qe>E$eZ8T^$6QU>tDfqXt7^U-Nt%wK^yR5WuIW0MxtNP$hkSOAV*Z_F$AE zpyj7F11B=G-gss@iS3&BI1$@`sp1YS(^Xlxh=aww0{&hOlLThXyZ;-bgkp zUuLk{pD{zqhw6>kVM$w;=1Nf*rQFDOtGg#rp@8X3s+HV_sp7>*cX6fQt<>_4gl{yE zvdsdMt<$n|Ed*G3l6F~O-soj_5 z>>fXC#g(CQ*`3HEUK1}G;mKkY=dd4Dr@!mHEcwi(2rXV}AC6~xifal^x}J)f%iLas zu4O1bB&WO2E7Y>yY%T#^{-lGL(pOy6I2~`7@4y~R;w^1I3Af2p#W8X45U1l3l@>#4 zg8G{(!2P(5n-5*QXk8F-eX*V;3du7S>r?YdmUOpUpXGPEzF} zKR9xQC~<3RYmBS6{XSbcoj&8vvLd?HvZ6h$WoDPhkA(U+xZ-CA-an3?Egz|e?>ocL z)#YHkLlWNo!8xOK3^$(HFY@Wlsp2%JXZ&Toxu-5`BBfiRbc$zs9N`W=xEv9U`^t*S zdr9-A4YhkKPx}6=dDts#;XN*Y zS?)QPQG>#xvRUTxXy)Mtu#`z$um)fnZBxF5uI|RnB-2AM$w0kd=-r~L%~*~%I=}6W z;8h}M2=Smqz;`aXjCv*GPj%E}R(HovBoRymfz9*@-fqvLj_jBm$DX z!}+kb%b=+rpm=8WqU!|QQjg49-bX(5rJ=1`dHEr`o|bqx(yQRZ86^RB-fqF-450b* z4jHZWe2n5Zb@W+t$fBS+e5w&T=-wo0g){EwjJr`LpM|7Nk-_9TDFeQMw zVh=D%<83xlS8&qS;Z3drYm?qf=GRy#xgKHlpQbj3>v1x`MVjThJ1$3Nd*nojSQP8d zy*u%j01cUcAeb$Su9=-35)Gv?T7rg_xJsdWQ)l2}yj;MS2;<>U9vTAA$AW)|ovJ;U z>Jr^mV;3@mwodeP*OAbI39ZZy0#zBh<85A2_x6;XPUkOMh)`r9#3QIGbCL+~J|Y+$ zE=LThT>wrovN~#QRWEUOO%&k`M*Q5}buLWr5^ZP|``;NU_Ur8Tq4ATr<&nv1%|$6e zTow1cOr2bCJ6v*1prjc6^dlTO$3j1^dvS8wU=dxSY~ysmgFg$8!n%gt#LYPbL#oc(lM>Sc*ejCFZCw=-P#_eR^XduIhS$<))&`56i120JiJ|n2G8Gn^!dfHg9JaFJ)<1 zV_8ucpa@=9e7IHx@nEqR1@(~HG~j}w;OKD9L9|Z*nN~>Uyw*Mb(vuVz{!(U> zeg~7?g3(2{;^{2lwWS9h9osg=K`nTMWkR)r*g(&rKD1>@fVNJ96wI_o6CV!l4nFYu zcq5`uj#h%hEUqEP)*^;weqU6f+auI#+1F4&e5xCt}B=rax2gC1B- zU+`k}3y^z>Za)1ppBMGQLxB|Z1qJm`uC!1@+^npa>)7{jmi_?)Qp6`(D z$GILmcfQGZbzjM&M_YMbFfkx5qI8TPSlQ!vZ>c7!S~l%>77$yAEZ;Vrz?MMJYdQPqPP+SgqJ zZ|rw8ni4+bNEa?lA{*_=3_X5l=-!Kuw~LJYmtTXgh`y_MKmUe3F;Dzx;|zde{)z>)x3- zg$ZGkLF}_f=h}j4Tv}}!;2T;wRbS4O-gFRoVy34p#Pq5|&JEuq53$c^SWE?$A#%mz zu3Q~$>6n&k^DjUxjk8a>n0v>R@M+(rN7*O!#pi!p(WOUOvBpcY34S>}etKeIo+!~Y zb)bfCWcJ;mn3Y@c)vAJZiR1fnw~*>!i`gHhLWqT-Q|-ZE(dEAU_?vs2lD@bR?E2>~ z>eUO!r1U4{)4LCyymc#{JiUrKNn z{hT1apIC?TouRT%2)_DgX5bxcJaik2ig{UwNRZqU-yzCnaG1qM5y(w)hZ!X^seLHaKr>+fV3Q_FY9 zL2Fple@M~e_7BVkjK+Dc@5|>5{J3Eia3zGXaNzEs?@-Q{$ozL|9S5Nr`W`0SjD{ph2`$~W;(a<_``)@F}{~a1UH-hZ^&vCXp_~-d-dE)o@ zZ0GeA9P~@R@dXDiGFqW0e?vJ~=S$K57j*-~N7a^CE!Rqx`5#o<%%K@NzmZF+t{xEQ zjbqMfVM9O`lRLU;4~zB*X8=8_Iq{#dWDJw7%ksjmZ*aFna?q13-m$#lbN6)7%8n5{ z4X!v;;qS-yw7v5wh3ku2UexoWbRMR7MgFzxIVR@g(cZ z)kfYGJYaKfFsIHq?5`W}mv@Cee+Vz=tjj1E^g_0}QrhsRtoYtnKSUb&E)NDBGjOZt zldP*A&koHNUk%XG0FoNZgRXo^_OHfY9vu6mqV2lNPj6EgrfpMC3eQCCA2d_=Vj_PV z0RED4`Cd#%{X)2DKxkm6A-ADN%{N!#)0YGDLO-t#@~y>S%Xym$mr+q)L28w+R&IH4 z?DDzfR4xxXWXD@N7|t}Phd5i;7Q6b=g8||XEDtK0|9SzYEc7;pq|@%PWQ4%3juEOn zfrweVrY`e#lq~9zF}=W8Q<7^}4rj!yZBvPPJ7S1GLJSSUH*IbfU5mNKr7cT<$?VaH zR@Xrx2zVsT@2qQ#KvpLBsabSwq&+WKd^O#HhR;~E zzL8&VyaJP#(fH%q(wlStr?h9zhZ$-nMhqR#ryYMeC|fl!ac_r-^J57@Ooy9C;4!wE7^6Ni>B7f&b{#GWcQID}P@kGJz zbr2(r;LLi?i_c~anz}673uT>4jp_Nv`jTARbD$Bk`b{Mk?fJ4O^jiaXwOFYA;SOkh zVJZI@qJ4=q_BN~y*3f?~mX`4Z%l)l~KC_~gOzOW)TfcQn{s6g_7mh)!5wEcy6@^EM zwd*z<{LUsqsoPq6%*6Pi`VECZ(ywG0PBXowpV8tgw)#;ZuS=-Udd!jtTA^zEPR3@= z4VfQlBu12yJP>+o6Tu%Q>s)MX$v-xhRNRq+h?u?5Bx=!~EQ=~K*5)4@NpkJTfkDlA zx^w6wW^J0>mIc()D6vI8yO<9RF&+PiH5j|cvbB*zORCpO^vsBli#`hzbJaskWHzy<4{iv zp||n%5q@JccYaYB7W3~kEP(@awJmXV0}k%UL@g9KRy|ly=O_TsX64>zL`NH~Zl5BJ zYNI~$=ih8+{sH^=r32$TChXd{229XXE47*zzo-jq2dn|oXubLl!mT@NRj*-Kz>V}ycYRib=G8LqU!9?z@Jw59S_q#xcHh%*ICUkij3dPYQA#uv2;Yp zIu{zl_{SELTw8MjBW8`8L@nDxWKmCyyZOgZHWBso7@aO)1qr~=U+6TLwQ%u$Sa2br}LoGh2EkAN{O}ELn>1ll*pvI|A!%!r&IbJ-u>? zv_uwxY4xp0!jj%BsWHC%;mRNWZ?5g1z%+g>bfF4|$cEgXqFLU_D@DP zKV-qf5d@On7NJa=+%lHdhGl#qW-SDi*t*sIM_An$=-L=i7{fL2@J+A#g;&~?tJ{-; z2mZZy=imLpziL5YgnryrvuX@#o^w40^#=s0%;g zzf^20e>_csXQ*H)bLHz8)YRs6Uh^mLn);ZP%CL5OmsxvT;R!yxLAX`V8KLTmAquYDX zF7NZoMcRvAv}%>+6u0H%N6cC@by>E1%Ay_{$MBEAlU&vZ&%btgAipF+4;M5e8cT`Dk{j%lU81X7X|#RoC+>fNz-~0@YO?d3R+{pgAps z^Ya(~_Dk^P|0J3)Hk72_VDq&$fJ}7VV5Y)TXK7_ zNsbzFT4GARvUUP{`Np4lZi)Oqneg`kh_aF8)UBzovDe>If@bW2H?S0&@K<8lwU}uv zww&;H=*QJ&%T-dh9A;0f`*nV+Gx%5iIzu4vpuHS;2!Wbq@Q=+Tt;(88v7(F!%avT= z<5=bSGglh=39gj)ZLZ{N2>owyrH>+V-G{hVG0qm?*J)V}j1Ta3qAM#r8 znxBX~td2F1_0agAfiM2>&vIVut9t_j!nk0nj{6`xd-OL4G{vZ*{C$Px<>Z z>N6oLEpPcewTM}#rd2)3-R2;p0uSp%E^$OuCoP`;>_o;?jq`RRK|;}}(&`aiWYH~u zV6>aV&kq(we1q3u;=dE2Sx-q{>C9S#O3PmqhiS;{U*R?1u)N!TJoHdv{_U#X-VeLh zLl1vM=m8tADqT+A;*9xKCtva!D8o(vbMY#W1W@bZHomg&fOCOy7+{v)Of&3oFTAkkbJQdRQ6h8>%n259^9-Q`neul40Hnc zp=43CW&Wzxche_bZ$*OX=N($F?ESnFAIr!7)eJ7%wZR$;$i?~5E&{8DqUL?S8+v^E zYlOTUdR z%T^R<%<`kmi!3~OG3pkeW3NkQi`$tQtOEV*+#P#ShLQQes6cs zU$g9hi5>+^bjY%aUi|3$RaUF=8puy{#6hiMqH8ZmtPIPnhzPIOj<1yS|6bc$RX7z; zfV!7+jMX^JzT-g#ZAGEWkK{+6aQ${-MF{7*ANh}Hv-{DHycR<0XZ*+|-!6oyrNXSI{{0zAT-_iY2ezF0mqwM> z67ju$f7mAbONB6ttwku%koLh+9uR1Y^D}A2tEx8ZX~us<^x;#QF@*k{{o|pfG-H#d ze-cqxKeE6WNc(%6qrL1$p4S@{ZHf}h|Hq>lFk_dN^}?@8foqe+J}ZT5T?)KjbobfR zSPSFJ1Rb-I*RXVaCovs#F~)ZLD=ow11BDjrME*jt!T9}ffP9rS;}Chwy>D3=pHOH$ zVRWTu;@^xuKvrtQ(Z6bZL)JhWmd7NnW6iJm-hOjd1vX~&g*Ghzt9lJ|4O})&F`xP9 za%1$rZv0s;o39#ie5Xh6+MELQJ8=yh9y9_vP@tAOPz=BLUOj^>Y8HD8%u=!_JxCU{ zXwUeZEc!EPf2+D{*36Puj=m%HYd1=L!2hnu#pr{eqxsW{%brcW3{q}aeK}`#S=geW5iPPiRGNEcMTZyS~!MF|JM?X7wI|d0?hIQ%oTH_ zku}aus+t@s)1@2`CGU8WOHZ!}c_fsPcAQ@GB9u-fCc_^!`^{4PcL+|H%dwOGnQmKpg}B*wAtv!>%8% zV*o1AH%~JYUOc`s?z^6m{#`24T{c92@4-aiG&5{a{3jdYEFIwRRqw6RA6JD@t989j zUQgTlBht1^(3jVS)%FpLZ|Dp3y2}k1qVjj-rc}o*g->HX86;4?6tXPX5-TT|rH6 zsi& zG_&;*hR;j6HYE&Sog0+>18ui3I$b?x-2yQ5at9@EA&lu7=rs#e>)nfgYgYNM@wHEB zTVI$^dB12vL4G1_Yt_DxnDxGpe@54>ulhnlf26(sw~i?LnY69djwpk#r)~W~X!#lM&C#|>Hd z09?p0k_Vux$$xSH-dxj<9)MR|ZhmeM^lHP};w#%#2c7iQlKG)roH(eOVF zsr~s>s-MA{ZyaUDx#U9r&m>f}_IekbKOqipf^PY&HycI>?CPlX{AU+(%r{-gBTFYY z;AbvlfPT^?Gx^ zKP0z!`3&*@P^}qS3$;dmU9I^ey5L}}K4+wx!I|}(k&pg4G7hKzMshJ^sUhTlDxlVT z7XKMNi`NKg0+(9*nfVG9ey6pVsO3dH&+JuC^zHj^r~5%7Fx8j^L@9cvloK-o!HkGu zNN{j47#!?>MUk?WWNPF$Fg^ph9S$XiaeHqQspwwCy=Dj7!IH@pec3ELj?mj(gJBED zd$lbTQjJMFd~q4WCFVVO`Y?^oWs`4h!it=3v8(URBkPJpIz;nEJKw(UKXbW}cIKg0 zok`jUoy%_end65>a$nZ_kL$YLj6Q|mZI^w4leT&2E~V;`_y)BoH+fgpjBTcBP1GJQ zbvrXUw8PODH_OBKTaGNcJ!u=a$7@lsE9O5hF96j)8?O1D*fWZ6MsM1;`(o$o%6STV z^y%9UGwt?m3;xmf2T+64-G;;2X<~UX;hbh%Y}m+2MN* zZIo;vQlD?{(+wMdM%R_xn?qQkT}l<-x+)5{-7kiyYKKN_K3Mmn9$lI@lO+T}GUdH? zEX}pnfO}ZQ&&`}c4?VUjvM+SFFIwDnAdiH)+On!L+c`#;_uvyam6$H?+nV~v*%25A zA&4_4#J2T0Y?QKZQ5R)dtj`iyg+fM5CbvdPk1r$!w$fx++t7_u$dfQ5NK9>V7m#x3 zmht|pU9sol9h_9&F7|Zh6?Y1vhooFRj>xkF2Kt~lr_=5{c<{nQWN{cOS?#R1c>Bmh zwPvwLx7XS zb@q#@7oOtHvm`4@TsP2pj)#|we=zO70mVr(oh|H#HtF$mZKSwgA9*J9(N*r0#%;}G z{z9Z=3UTdL``8b098idtO1NO`vHNu6<#u4?|((``tV)hz9WT z`j;Xe6jkXEV$p?N3GSq>q)sI56GR z2V*j^BFrh*5!_$`N{YqWe^S{OvDy-z#CRX_;*91vr;Ac+H)+*57)et%=?$GFzL&EV zFBkDZ!{wCTxGqMOGa7=Ik}rRPpC1^m%NU=!g-jZ{tr+tqJ4_0DL5@VG2OG%bN3Ueu zgC`PTxR+2ZT+Ww6W0{2dAZ4^$1}}DFy^q;rUN*epeKC?nl=g0NNYmLX zKc8FYq4^aGPQ0viH4Sqwyn7hwF~k~utL4%Aj8_J^m)<>$I`}SCOf{6qBsTuz?km4f z{MZ7N_iepenYb1`JTIkjeImu>mTrtuzSTw&oHE2iRlX!cs3EVk|8#|kOD*7Ls#2aM zzq91$bkc*ThPhOdjd2INPn$k#XHG9k9RIir%$QMfQ))>Kn!PP{92|kf62md_l@RSt zmmfX$nwGo^-S2gnXhzF)+>mID1NCef-98`@es_r$t{wsCj{CYGjieIuI(lW$O+^d$ zj;HAgy3o^;Hyn322qyXdv*-?W+tyK{5sBW65;Gvr`}j(^XROrwuc8LGS{@UinVfNj z5tqc_&GMlM6#GvnI(Y&FM-aa!V@Cq=xbQf)QYyAJEd+&wFVVrJ;vG7lg116sY`u=s z){Q7f+O-Gjep^QmSQo(|9P$kfgpi5>aJjuSM^r7~GDiP{V?PPe_!Ls$GC~HyNZ%Ou zc|rO>@w}Z@*bTrV7HK?W$^tdgOYAHf4B_Pkr7ppRYwvJ?0D}H4F$4zEp#wq%*G~1N zXghE&y_}!{6afxDd-zFIzYDP-^KMZ$Yd0mN$);EAxR+UC_Bhe#syg^c%VYd~E;97P zO8qbG9b|wLm?@@e^~Wy-Ou(SBNVCp#s3Pf{^}fD40uufC9od!<3*c2=rx=j5R{>u{ zCD*^Ckg0b98s{z?JVg>YTYNnSnENW=lju&oe$(fQw_hCNVDTo9H1ipy^1xTUO}h!a zTPdxHKIFrVaUX!BA;l>7dzt~Ibnqpf0ULPtHa;*F$Wf~QQN(+92_=pnpG7l)v;dE3 zQ}gaJ0=dV;9Qu?K_|)K*aD68)TEOTy9_4)?h);fRNL>ltluAH4q=N+eW^GQ5By04@zbNuA(HZ$Ei9H^O!q!+R3 zu`(d+ow*P@`n}RUZMT2b75CC*dJ3H&Hm{1rUSA?^RN89wi{yO8?Kg22a&GHy_@?PI zjbjbY^=V$Wxf#56+1p%$>DTzj2cCG0@3HH!mryY)Xh8*EIjhLhu}*fcKH;0ZPnxW# zzS4KayN+I8-jmWD!!4M(DZpQ5Q@>4+lKRV`*P)l zn$X_rbU)lZk7iNE51F@=kI$4I&}W3durAdUXiUBZ&nqmbV)WH2XjEM>T%}P{Nl=O% zEy)#s)Lj)Mcl2emDnRu9kefp1H%kwr;#Dz%PCLmICw~dwT5}nyA3Xc*&4QshLPXaa z${r3L4!K-rEzhwhCq>-0tB*psQLy^k@Q&8RJcqv|y?5mBNC}&j=2`f;?H{*8)jnq> z&*Y7rcm=&Ekge1eBM?y)K4+a3Kh!+uNUZ&(<*`h&a_XTdL?5AlGGcVP>G%R?vTpXA zQJ0)M;mvQATJkCPmZuvT_gTG0`O479qA~-A{lLPe0(K^>2+Tp}43XWdQIyn%9pP&&F?kDpJlVO#kq3iI2&BCm>ZFU{eh0mpK zCu7r%G>8p28adlQbG)2F1K(R6nwd8WDmS%>nxiZ@m>pWGQbrl?)FjMzp$}6^PIf$x zchTsUq0Qc;xHw$P`D%ucotEu|@MD*!InHV?N72rR&v7rXk zK9|w%G9YT8cWC}W*U)^|K#UuIi_yJ9P?N^e${!*L*0^HO2f!3zyIrU3*YB zNqHzOje*sc6lWLrC$CYB;E$jB_Qw*_HI?_-M!)){2KL$xpIjinaco(+7;AXC+)mRQKHJjTT)6d{BW<))? zk-{NPLm?`zW}go4p;wbXiz8YljSwHygnHz{)>RBQbH1*f*B@h5qFd6nQ>(Iaa8h$M zD=V#jiNitvN+%5~nlo zuO5~!>SIsr%dFDRzp@d|_&!s(j+#UGyUa5#0-;~ZO>NN8A=`$u#kV|-WgMucz$_bUmR5XvvJ$kSvA%`bEzzxsl#l=<;eegTyUIPri&BXV`6+< zz}pT6L*CZ%HR{n81*a<_qR1|%KEbk(Qpb4iJd#zr^%uyfCoe|}dj^sAwb zof4O9Zh_fqy9)Q~D#Vq?J#1#F&O@bK=~|9XH!^+tQ1LMM>LxyAsI~)fDAl|WP7ICn zW?^Q-!~%@+EpV@&oU34K`>ax5cQHB%#u^i4xIb#6tKYU(_Bh+BKM) zi>vvS>fCJIS-^}7*IXP3J)$Es9hS9F0sc3#Tzp!kW2IQwl`kxB&WG8nzeZ(r>3NjK z3n*P%n%qjFW)m!8EGb#Z$kFr2(OaR+IP?#rwxL!Di{Vjb;ZXJ~FP-Bz*|j}zv>Q?r zuRAMVH&{yE;>3gAF(_2muZy^i0~>i(N(7a(Tml5StQ z^U==GNCwGttI1f4gUC+y3M|87jD5~AisxmtT35OJU9=`~%NlK|RiChF>)@|7y(X?1 zXI1<4h~qNO!xHY>So>|)x!N=AceeU-j9V9k{k6)u50^U10ofk zqo!XU(N_{S@3fPB&uOo9R-js`Yp3E#^4o1N6nnKPws`RWDW zJ-e<_a%^DA^o$yIJrUnEx!A?Nnx$sXGt*nAcl6h_2`G5bVB{$Ti71?4>cr_mxY+fx zO?l`2sB!(iI&i!oD06Fbk8G}cJEZGRZlrvuJu}`{V?w5m0E!<5+9HYSKSIXC=I{-7 z7Ho?;5x7LAKlt<54$jLx@to{i8Azzp^(O-(_u>ZySV3TYkl|)L^oI|ScT}1lPy%JC zPPKv5YeO0CWEZXcl0nr?d(>RN)1I;GLw#_5`SG*?YWI<~syJAOf)sb^TZm}1HH%|- zo=^cL2Ul@)#kK|($G)?7yk*c~a>>$i?KG%6KKSAh@two8P`r$Ou$aXuzjY=g! z(zR-t)bUWypPV7n&Uz5x;BNNXde2$nSdg*nI**0UuUq=mE*GZH549_c{)!1QCZsN8 z&*si2qFh_Dwx*SWriobSSRGNg?CD{BAa-6Q0t+)S@BeQUngZtJsC<4OgmOU?l|AA&dcEBFGoo5ZNx z1!T(Ia_WNv?fXQF&d>&2AQoWD5E~VKo;ks(x)D?yYi{e-OVG2X3KldS#bn(bFMs2ZvvjIQ0YE zz0>Q_5|*0PGrD1w5FO5$E*f@vEt?nZYo#Fs-($I2b{7l zaSD}(#5zJG#YqllHH&%On5rnXybeRmi*%}0us*5jnQwSccNSumbxBgF7ED@Tp^oy{ zL`1*1Yj9gQ3l@&R4^h`pd-E!SN17tLCO3UYj@Ap)*_`=UiqhbebHh7D(cVes=rAqh znyMA)32*C_W$Upzq%nGl#o2+S$P`X#_;IIb?dEunW~}6Tz&VFS%NnY9*npON$6!`_ zJYUhTf+IKAq5kyhB=;)H3F8v3ll;YByuiIuva;9y;Y4e-l}A!NnR}M6Nu;=7Judu@rP zG$IMD+0|vhb?DB^yMUO;%96BvT^+N1$Wc-@cn_Z55SLeXSjT+Q&bmG!A~CJ#)Lo;} zqPv4FOFl1C^MkKM?byGPYqV&kX3s}sx}o&!=yg%8SP4tBYX6NyA9h&I=n`(3?d9^E za}E{jH5dFfCWgY!Dc7={w7*6jE?06{*iHD5Tt}54;6|g%BGlw6H^)|vrHHMKuet0k z)hDgFvBT$Qms-U53W0P}s0Zj0JudtvH=*bemAO2#PC(Z( zi;7s&f=|=`Y!q|eTY7+R@x1al*O3n2LSD2!%=>2`g}{fsvg76}<(Z3oqfv^^dnPZd zR~LaIEj3RQ>};W*rER!(_0#O zsQPVad$pv2GPovPa8@KVxZEJI;IOpx=TA@QQaH-orBra%ak!HSm6NzKWHeGZ#^hsQ z-|WW1<#1DJqG_N3sX;ex5n^%W=8k=vt8q{OCz-na&q_#4w{ z#xdDEvjv-l0(i9>!@KsV@!{QZQ2offU_Us$cm$lT;V5lj3G3&bwFgD5jXoaQ&MufJ zczO%CHy!|LEdPPg=o(k9m0MV#0DZRG62JpkP*4Rvc50@BDQKp-Eww4=kmf4jP@jFG zp{3H2=dWwR8LJZJu=AWq4$I2LOmlv7o&i<3&N(2WfGKjrpea$ZK9zZe3dqfA${90K z;`G(Q3DB5xKhnXVH3SP2n~n(n4%wWp`Me>dZ<6Yy9WYZX0%K+$D9~yM3I%&ZB=Ara z6Xv0`p%F$WQ%Z&?n-S_EAp*Sx`f;*GjS=i2rXb)E0)c3AOV8=dRjU=q@D(!`K)Fi} z5DDcGPJ@n`;r3c6dypYfM4(J_)a0H6;H)_l=9vsbV48jPpVKtX{I(Z}$ksBL;#K$k z^&>~MlstG=VvNQEF#)|XvkUsabR8@;v}IbF6MQ5~H_awG%q=I%l6AUsw-$7}0+=*l z_Lij!C3(Wmsz3CbLw&|Z)`}FJklDr!!K}okg$kB2f6Nm_Rn1mz|9&dVrL$z7g2dA@ zM6;*TMWaJj{X6uSNrBLc`gz0DP^t6N*EwDlLzfGR?~M9Gg6VI zsP=tvqC;4DVolfFQbmOeEI%;Hhj>Gvr-!UVx*7LS64!We9lOD>SK1k3`$?hJ5zE(EBk!C6E%%0*Oo_J(NXC}gZae}M6_ENHjcjM{dVU!j z0xs?G{F}#t)Eq2=QPQJ;+`du!+}=US>D_Svso+y>%>)P<@WNdb0~ z`RD@ZMciG;i$hMx3!VBeE9QAch2~@m#pao*IXdyw`+_bbjBBetK-uL2p{KRV06J** z&>hkus0V5>w}(V25GUl$>)H{EiJ~%c+%w)%v==U}F9_d*qZ3}E3i!T)J$JZM200Mx z3FTS042nj`7s!DtkOQn|LD9RO_MkNXqjk_qYAnxOT;v30=R`1Nt&V=Bgy57eECtRO z?OuCec{TfRFLR3@EjWp!JKr4G13zj`*}-{dbubGY2lBl(Dn;c#12E64r*eRzceJA| zFqe;*F8a6}?XIC>yKX8ow9WAoh_F(gU2}OB1CY22Dex>#{t;(|6P&e5x3M6r1Jrfw z+3|rVZLvNiiYMCoUq%nF-*JgPjjMT>bPQe!&FXuX*c<4=ICCJl9!>CA@G;QX*~lj6@HyoAI6F_sj|_PsdC*jq za9KbrELi}3h}TUCH@ZuVeXH^~W=CiRXOYkVy1KLM(nOlb2b9B^Af1q%@)=+v&{1!i zvYLl$gcMH{`O)upKnbM{i(x-rn_~x^WPo8n^sv8D!^9Eg{xUVu0BBbzis(WepeWdB zLJZ&h5!Li#7Q6mWyHs370RsdC8{EH+FUDUG5WDLD3b%uI;|fy_jJ z7tht42dAmuwcoLosj+-sTaxF`smd531o&$(yIK752j#qO5NKD15QMk=k;f8%z12gZ z%ep;yqwwvk%H1|?(?Tr6Igyz=*&l%F@r+FJQ5Z+G2mjFAc4WT>P>j=u9<+7Wz$!P^ z+~TB(nQ7kXU#;&(4XO9UT>y+tRdWe%SXA=rL8xXCtcdilpp{!dMIO1DJ#nBlNdOcc z-PMy2v@v%P&AdwjRLi5hU7&E{hLw_Qx95SGu51EMqVJw`ZwFb2&E zt!xI-h6Y_ytaN>5bGdC%X`DDP_=jsj0}tA`p}hcXvN#|i6L-ME7X^i*Cx9G4p1&>t zi=uI8mmhs9&98jA#~6?@HrJ_d&-#U@UP?n6nnn zaD7%%z>^~CR5|zvNY;$@6TmZ+H~|AIGok|sRB1uEiwq=UfLR`Fcc6}^bTRD>OF7xY z+tJN>Tzjz2m3`>QzG!_w3gOj(cK6}VTQDLZT#O?w%LB=yxV`Kmv)*w2*3U(0g5aI< zL`+VB+fL!A5v<=&Zs9Ceq7UWy(RkrU^I*?Ip1;KOB_=4F951%gbHN0|k2t0mfCM4z zU)FA5akPtxH|wW~0h*=p5Pa7-D@>ef z@W`zQL;f)x^oOQ0L||Ml5Va^RxCOqQAW3d*2X2^dn4t$Vkzu%AOz#wTj3a;CQwWw8 zXbDdckK2DT@TG!Ir4lBBAx)7Lzdd3Oati-XW2PWTw;R}Rpcmy}DMRr$h29*e5rBFP zx*Y-)`|sf*5Nk|~HaXBDn9czadIG5AMQz`4+I0XrcNPd;(~*C~*&{dFrwx2)!%RBk zX+1^8wN^;PR^y^{X_v4Olp74k%yb$A>J|flc_1)qLIktV%=&-}>I8_Rl!}g{;>LdC^FzRMNWWWK zWKSIUrb~(h5f9Mfq6t6?U^~=bkbBL}V5>o*fOiT2*&Ud!cLIkqZoj#MDChtH0APfT zz)JRjS~;!>Q+|*PAX@#*riySN&w-&rKwowRIQ|xhX)Q=OrnTh3+Q#(xUzPt$V?NY; z9o`o}muU2hc!3@}bsU2Bzs0$!=GWN%>@}?)biWNc&jWQ+LG3d|R%(f?`<^k#zI zKH!`7Z+3M6?{rMoZvP=`ImBa3dIE41Be;0L7;<_PU}zXTfui_7I&J@RXF>#^O2_j2 zZhlFoBN{!7nDHr~0hs{-jmPaLn2rVtkm7+wx}yT{6{w<`WPn%6>iZx^%m)vER}Yv* z2*^Pa0+r%l6AG9&pPzfa&g!0|- zT158MKj*~CEuk=SK=ebNYEuJ9Oqo8-Jows%avQX8I_pUrP#mYx1&M z19+{uf9M{JbL*t|AXNaP{#cL{WSJNf|1XA2fbL-u#!XCymn%C?I|<;7AWuAX6J)_q zgBbwK@c%}Qr^Cv$StHO&U^iBD00(e@ra*5V2&{U`95L?!Aj489O$>GGs)?t5TELG6 zRud$id93rgzJJFmd<<6YWY_%-tB~XvtXls2{LDpmu+1?A3QoyT>5o310uvbn&}3;r z2&w=s58#Uvdog4W&nfVn+7LM#iFsf^s2#8^eQ4^pF4Kck+c3DeTs$KN)b4|}Q!hhJ zAvTu=ln10a3!n?o1J8i+P56r-2&5ptFd&5)Le&jgb|2tkK%rxXAaX%2fyCtZ5NcO2 zVAux2;5!r?f~%xT&>uON6hGQn8U!>E^|5FP_0(DN8r13kq+ zqYlXRw14|H1M!{O-v)zB@4pjqibenO{Xf|C7uU|Ox3eo9CQxqzbW7(>(1i7Qpj{wJ z^LGRPN9luj1MqDk4@~d?Rtj(~(Q>@+=DEqlDbECl8PCE0I(ba01G(;Q8f@mVZ4P;ASeLxYy_;Obim#LTGm#k zZIKTtDSDsDZ&|mhWusL;xq_N8i3Z8JQ~r*vY5^nHroNq!W1S9t}9tF^S>At!wQ@+ zoCYF6WGR)Gp=F>Zu?B{=cr>wd0ZnQGyj}C}?vDve)gD9wZ(bC@a6_QJE>5n5BpA6w z`$!7f7yKa_K&?~J7`lXcz!OBRQxkuqmI?+TO%FhCe`s!lsFi34lr+Euaw-6Zo&{FU zlQs-Zwgh1#zy7p=L36(V?^B=@Fhh`VLjo~W2{VKm4~)d=YYH<2*#@Ws=s|Gj0#XO! z1!Trw@B$3&-_-r7<1d2$A`N5$%zrWg@Ocaq$bzUHgkxazAiMyB^cN_^1OtM?|3(C6 zW9eVM{|CDuDE$B3HE1D(aR{?M`W+kSeFh~OTq&LcAsB4TzQjKw@!z{~3Yh`EdO=v~`yWF5isP7*htFdtGccfGS{R(Y89 zZvfpmo`T?{R|DRfP`|EYDp&cqf`;==6Fh+8w{?|V;8q({_n2L`e+~ohfS9B1PYd!9v%PgXgy<2*7hsk6hl}8*BBp_X4YdDp2rfp&XW!c?zU8EdmVd6|Fhi(GK+8_K5M~Im5YVhs5Cezcsui+)5!}!*magak z&<$fc2r^JC9|7Bvr*|jjfnsEU3-39F)a*i8bK^K z{YdyWuyi{O%Pg}W$b_H*J!L{rYk(`dwna+}g@WJ;>VPM}hQ|*&MS!qCodHh=S{So) z1Oe9RqYz*Lt50y7YZBy(M4hLJIc|se5vDkxp~mdG%f$Ixl`EIt+plQM=3EV_#D%uW zF+IcV=LmoYRGl(VS4Nv-Dl+HzBC!4f3XOqv0NTkv)eSDz{yhZOZKpm$zyv)9ZeN|s z1{R@zA3A`zkxG@*F2iwtdK)@gU@(7$7e(=l~v$oYEl75WFOF$bOhh0U!yIY3fm_$mUCi3kSjoO{tH*YBnILN|BVO?oBMC<`hUu`tRuGu z7pc=wYVW%|0=AA$Yp-SE&QE1yML4F}p!FOkh7b<{x2B-Ffj|IL+z?3QOx zBAdN3Q-wrJe+m`6wK@=ViW8~dP|G;27|js?jmnjv1~}bk0IXXdshSBZ$Lk6>iUFD< zOWr*Td_gt@SH4X=}%%jG+<}-vdruH z{2jS)e642A{M^O&g+2!cjHNB;lV%z|`aHu(P=5vN}b_;WK0V5A0VHT&Sp#VwO|0v>g_4MD^_5Z$WsegsD$G_nW4y8*G*c~zkzAA@_RfZ_g_oA56 z#?t^chM?*Mcg}%tbYMyn+N;8}3uaw7iFw`r^f^*8uk`|LnSrGP z9mOBVl%p_3c^frq`eb+TWAg-x?))T@xNVUJ#Dwm`i{VGP;x~prlK_SwnkU5Dz{MHH z63ojUL={%dHqz-(1b`r@I5OlZR89ru0w>|G6i;^Ruy^8|QpeuLL?qrQYMKtupInIG zOGOm9n+mvR(KN7dxy%tRO$TS>CoYlwIN58RC&<_iA*9 zqcQ&u%54SYrlfy8Ft*ENc#3*+JQ60m?g^aiYsh`GaHjrxKqJ<=%sp`OkfZ$jSD9Yx*}ucU8ZL0{fLi za2I;MYJ#lfT3ti*;{B1U8M-5@BAwC-t-RkM<^TKzq9Ii@T^u$R)(0XiEK*Yg~(hnw%?D^W-*m__mq@RdiQT{oRr1;(Jx zYK>z?;r6KQ?VZ6L#EohAaE(&3(YDppp7f_`AGedJp*nu6jSF}8)QCl6VstW$h+q${ zwd^YpQ{(6sX%8%r1qQDy_iPZI>$4P;w)`Bdxw@S2Rx?Zw8;kIz#O&x(#8ridIOR^a zY<_Z6<|e)zgf-C$a#&H33&2yf?loi^k>B>#DtdhKFiQGOD)9plQny zTPouMta$g?^J_x=4Axd@H;U>>lMaH?&7wYE3?%zSl$N2D(w#1g=R?|-O7gmaFZsA& zPrH7O3fDuV@z#1^YP$%LhTs+1Ysi&UUa_<4sM}|lK0Xw`pXEk`XUY{;`|?fgV|~}R zI8wW}Sjezh=7!y#eoOvne&>z4##MerSeF=$qQ9~8-J5>(C5lp_QAZ@QRWpt7Zfv`7jLrU(r1%d#v73vs?RzF15+xbRWG7GJjAp8r4!E* z@@eU9V=FzCWJBtM&&+p(LdltRd?xI%r&DPgS^04u>}Ai8-C(Bir@Au6dRO7{b*9pl z&~quYXYWt?D-#_1w&)P`Gq-5mM)CTtmowWlyI(fW*|^<_=VzPtqBq>EzBt<}W4SV> z`dQ0bmH*>)N!5_g&hdkK7Qb%Cvr@ZqJ(v#m#JR_0HL?7_F=Xk?;ZoWmGPS=;HGZf~ zUivw*mx`r>gLgODq*X^ui%iNI~y(Mi_CXv=U-uZ?D%3;Hl z+7~k9TX$zNV@NzB92aMN>`JxLaV2cCYW3etyxo=7aIzzuvOc4-ppl$dEF^mS!LM#3 zw#iJhU)@2K|FNI%8fsDO;$UGJ-N3>k#PqZ6YbP@UTd0%O+bpJeFIA8)2wo_0+0mED zA#A}vNmG4Mc>B|pg^*CDDAkYLx9^!+KK_!`>4I=8+oisjNG~TdQ{R3kZN*bEfPX^T zhQEeZ*d6{b%^D#o#gDHrA7g$!yv|i&@D?|`!q*wQr;%RIR6?NUt5a>nzWWy1VOk!> zR57NMfC{^OvUK^~3)We}Ctf<859d#T%N_H)Jnq9pW%D z~x~m$UYDVIj-SqmMh2UjFXSdFiP;b7#>y4hSpwO|6pEEhyx=~#^0UrvUXz3+6ho$ z6&>p%sDJlJecxQC*f=%9kM^ewiz?2!1jh=&wHKRQ7wU^g`qqWFccs20Odo{tHS#l< z*6DF&`Mj&M63{ZPQHvo}AyWu>@ z%*#LC^KKtC5?u%GMj3Zqs?We%r8g#F4OhgCC89*kffM?52#%b39xA^j>*_U(x29(X zp7)$>a~=ON4vIQ%wOG>tWSd`0v-DH|XU%nW`$tOUUgnN}<$yQ&qOR`xliVIDn2@zL z&$Xw~4>#VwH7i(B*^1dhXv#j+xHZl5fU^D4bIPq;$xSR1efh;q*%5ac*n$ZC`H_^z zcSJM9NiW~e4`HXNhLam zE&R<(+C0ej@{csxr?R{(b^G{1oOgWdL-Dt+UjJ5m`@Dd6ElIb&%#^zS(lM>T#DibY zpR72<-Aw$TMI%{xc*S$=Y1yKPB)|Dm2`iDHY|b1xtxw@+Gh#(2d4^Ir5Oo@&pIzAU zKZ19@J-sAN)9`>;)X?e-US+-Zr_LigfzoSip`S-62lF0uEbCYDA&q=v*V~MI&rwO1 z>>d>kXKwI~;w{UW@e^5nv~QHI_`p;PIfc*+@p_v z7%dIYEHuuhbx28P>6CZ(Xs{6NYEoaL6jdqW-rV1_8+Ax^_@z0yy_s`@-d|8^%{n;m z+~Kyh**nNcqjNGdyf9-aT6xdedS$CYctCW&$fvTe@<%^S!#1ULK8Y|>;R)+KO}_%( zgpjrknRopw_D3%`ktoWRcgAbk`Bn=t5l6G!M&wgl_DHjKm+zLu2_dV|f)Z;q+m?aC zEb}6VJOuJ(pIp)lDjkY64nI#)7WDH@uiCiU=BT0Sh1f(9L}f}>EQRYQSFwAAZa3sR zofNW9$>w*K@J$>9>IjWTut^AVWk!@&p;&)qZMG$)?()x1+cvwpyiXWDfmyyNQ|86b zOq%X**8Q6A>^C71vM*Lv;2)JWB_Z!8_QU19hSAs0I6A(c3SqB6nVipoU#(or|uCZW@ZYCVV6&d0voka7)o~_&v?&q{G%k?*5!q zS)Li}&Tw&vP^IlTx5w^Y3ApXjR_v6H%&u?uET{|pD#m@+`7RbcRISx5NH1oPk2 zZJZI2iY}L=EqNSZX;sC^E?~eR6LshteWZH*06n$eW>`+8!Y4)(ptEmKeCee4XU|3= z)#s%sv27>nxJT?&X|RsWdY}A-P0qPBs^q7%kz?;$cod1Fxja(__8am1WCEfVuUcUd zITcW{nl}o557AxNoSnw)*_`7{#+|^6i+^I;pX1(4x!HAoy_u&Ji#ey7(rMb&fq3qA z#Yt~otDIe4nG63;e9V|KgQI3m-pf&(lx+OtNn-e(vB?TbU*)@>>%D`nqz~!BaffISWU-TZ6qjRc^>DE(mR5eH*0g zuSdtcPT_h+QA5Wng53Hl0`x=G`$xkeA3nDTi!x1}$Qq_Da1w;Jqh37o=Db~3JwATZ z{dhqq%k5wqelU;kIoEf*uR<=%NU?65(p)UnLha_lVn`XZ)v1x-9DQ9j68D4s zT=7_>Z|XH7GrzsmO&xL#)4eH*wu`*suSORaBuyV?fRaQVDoS`RPUx{Det3NO;&XO^uU-q*-R@Fs)Xx?D0idcw|DRRtz zE;;A9v`Z|)Z+`0OVLwkrDfveCM3+^lY)y?@okxWQWqYgJJ>Z|OF@*DK^3$CgSn~R5 z&)&Wg(xFoDT*&uk;yALZ~@ZSCqdakQ@DdQVe6`Rq+ic>r);DfbfXOYvP1t&l#Y zNudRE3U*wxqfN$^5~ziug0X-8PY zW;Hd=#mgVf*$ew$yy0sZt(qFqu}y!3o2RDZYvOX(k&k!UPGVe|_@&XCVt6j2xO5)3 z@4RK_glAW^#FY~Z9>LnpdbT-O7EavVVRfW^+1{+-cAxcEpYgBt_-B~wuIad~uZ?YV zZoGKqn7zCf;}S8>;H{&hys20 zk&x={3c^;4CC_TtxAI}xxs>z+F;`BPqDm`2lFl2PcyH>&e#ZBr zvR^JLzSno?H0DiSd_a$SHqewKu4wFCMd*=FnB`C&ey{G%%hVSagC-K&$0OI9oA>G( zRL|oedM9eUD`q1!^e@;|1(DWzU20W*5X?AmdHEY}&WHE*b8K1&&tcr9<5iX(JBGyS zh0grq_R>g=bN;ui8!KZOVh?MbMjRD{tNW+ey75+|^Y-lI%jefWAVp>zM*McD>Q+`plxQBH`tU5mcM?FzP2# zse|XSGDpc13@*KU5l!u0cXCOE*yG(`iv{Zi`dtFz58={B`~;jE`QiQKw7bCHrMPL% z)tPjmlRnPPo$H%um?I^#^rfpSmf>#(3skk@#)IM@0qdTz5dw%N~16iqm*L zu)HzCL3mb#G+UZ#BTTTX#}aQFB3gke=lv{gVtyM>m1{QGLDh$s?wL)8x>ZfhC;-b z5$%k{#2rMAF)-3Gq8r2ZA^NWV9`w=8wlpl=H?Vf?vKTDwRQVp;2vxGv8fj|GTO<_H z8k4t8omjG~zj-=UB(`n*;1S{-`FR3k%6=;SDFWW}_SbIVlwOXRNV!aDb(@*+C?Ub8 zi^0&SlOOBL{q3nXEoY1Vbsmjz@#A+@iTtniUeEYelSnlZenC{?CfhVf-`>4`|BT1? z^Hj5_w}pgEGktbtZKLztj!^DG*1HzQQIC_FUrV_S9q2Hn7NFGi1m~_|6=1D=AAkDn7H@I_r_##I z!kI#cBTn2)1CMpXBj-)uQ`0V$<5^u~j(T;c`h6MSy>8h%_VXVN6U6oak zkOcQJnERzh*XMdHTb@SV_VgCVOq45JgYjkFNJ>T_Gu-tr5ftu}?sI?v<(w z%_rHZ??$RBOAn?MB&4`6Wf#<&@6O><$Ed@*16Ux8;P+AC6$yD zs81UXJC9{Ole&NXdNlBW4kg04cYE#Y*n0=>#RUG%_6;?&mLf${U@YuJdot?9~?XwkEOY&XaC+U5q{nfcYgaa@y7+ zI~kpdWl;;C3m-;4T%vPQnuwIZr{C#&lE~zQQh(R~lLQaD75G_P&u9boWC%l=W2~|ZAqug4MIhPx(umZsp@qe zzCRNm#vN<)lC=(3O8EwC9V?vEV4LI9H0#S>!g(qCfx#2v+l=!JRdJl9hgJER{_pti zC_j5!J2QM4IW*t>kbK{VvPUe91QLy)~V#u}Pknp)=!{ zX$<$`D16}faq#OU%?cDnR-x+BI?WpO0X;FKhuhZ{*J!qlVQ`tl};K<82NrtXDnciBS$? zy9R4LL%RkJfV$NhPyD<;ZU zRg}YCn|{b%R&jSp!ZR?tTvLvgQ#0wo(}&~~yEpZj??+_s21@Bh63{o8RQYIX_%ZwF z4(M@f(77MD{;$CB^ zasE=rVN9=nO*a3NoWbWPP=vqH8dbRx#Y`@=FaL#ZB}VIUGCftvX%WBo5do zmwH&Yu&~GUcG$jW6`eo)%5wP$4iB@8qPYS2$&8yY0o9rqj?xpxTkv8pNA&p@!_ObB z$#*{}TdXB#)XRD2gp({%^>q3J-FEAYFIqE`y=jk&FZ9ZNw;k!qYY|73L%h|ZS+KV! zVvUL)^jn>Im!Er(X@~L<#cwuXcR%9Q8U3)F?cY43aN!j);$3c*sysF2Cq7bg@&|`> zHQ4UAAGV0#tx~Uk+hoEmp$gKFe!OmbC+-RHYJk!Zf;(~f>24e=q0CaI*KCMZy>G>; ztKT|vtNYC7Dtx_1cQqKPdqnx)iWb{tU8+2Prc%)`%WO4JPE1#_$WhujA@a4C#>W2v zJV3+0%@@4BUjRbBs#d&a)&E?y}!nqexj{(-Skiq+eu$ zL*S%4Fm3twr7s-FXAy6_0eX9wyS!;qpjg@HCRopM#pxuQZw{r^CO z8k&U~D(F{bFdwRDkz>r50B1zt;^35Ush1DAHS5zMsJRJbBRj$cJH*%t5MxUq9Ur6o zY6)QoKBJdQeb-b+|M~ub;r?!8k^M#YJ}0C)+(Wx!_Xf|GGVUUNJ_{U)cma_w*7+M& z$9QWMu?8wtBsq%Zg=4^dFFyzqB9OsTdaatfdJDZ6JQm((I%1io{p$ zZ3NO!4cu1-)uE_;6zvyfai)A+~tA8n43%G)xfTyJ$2*-&=PZ;?HnnZNXqV z-Hj*A%P@q;wuh5Y_`CMdgzc;mTSfwF;Z8J*uQ|^H=i`MJT``A!#lG{UzJdO3yMfvJ zoRFGvAE_(maG(zilGF_>;pelk(NN*%GxvqT@QMzZF@&UK?SWp&>;ljzV9%j&(!&-z zCSh1)V5*(nN%i_o_83m&PxlAcq(4>J^Y2{u^9H49j zp8AD+VOyD+aDEkgaGze11z4%zk&u3jb(#2r{w|P1Fm)3#Y0?QQ1np`7*}M@fwav7g zaHI!Hn)H$;Qp99hlo-1?Jzg?Uz`aW{In6p4L4sgG|pEy(7^f}T1(lbV#d zsD-w5VLOmU_@+zdvor`UYv1>g*-r0sVrDx%v@7Pbub2fN@$=a-Gpv#JEvPO>#6rwO zQ)z%648Ciy^xyFRkCh$##5_JmF7`E{| zz}`H&lxDBR<_Gy#Zl+r5kqI`!)kUWI)vjemZmrTzl6YVZwFC#rRq=qf&{dO0vR$w? zKk@X!8SAJ4WE4bXlxsZzc|hkxS_x~nXvWX#zB6=3xg1b4rX9wU(g8&o=2MNQ z$P|t00AC#&pO|z9*(e*}u8zjgS(8n}ZONwLvm~2ZHxSgI{C2|w4k(lYpd7^62OvfQ zf=3}w>&99bRW<9+9w&T{9g>`GBlQU|->Z;otBRMa$M@G@N<07qgr%G)(zw1!1-{q_ zw`?A3I;nq3-XO_zz?nq3romWQgZFGsjL-ajSZamW!VWVF(69cSnUH{CL^cD~8TC)B zJ<&Ke%!q9B_nh+tSp)0y>h6;LNn4VX;fL-GhPEqF~Pu3@IO<{9CF=}r>^u$#8j z8>tw+3!97`;^tSfsU93K5Hm?H>^=dy9@j|5p z3JQVP_ z9NPKwIr1x4S>5-vfrRWyd;mVNB%SPu^3>*8;r}H8u6e>_*oAc|5RJ;jxVJOTx7ma6 zZShY6V`>$$W4pw~bF=b6k2I%r4hmQC4NM^&KdW-E6&Q8Y-r90E`5rf_O;j|@8r*Xm z8?kH&P|!h2hu?xxIDQRCVA!ELKR{04?x6?>0T&9Y zqzE6-tG3bKCKOFkHi)s=f4vvdSZXI~>e+>qrkPaw$zPw}l=xKvFYK#|9SK;Yv5fCC|Vt(FE z=p^(cuP=I(u&VAwvW7Ls4^-GDF}nDC&7)O=qMJe_`wU`S+T;}TGfWJ^S2m40r;wBd zBo|wi`2^r+2vR%S0LOuWy;2kC04pCj+K|vnR4;+Kma8@!1rr<}zF+|&kpn6Gkq-l{ zh3NLZHlabiD$AZwi2+qXE~o>bQG4CW#rGC_`v;MG={~RBz0V08B=^uR@!B&UDR}K~ z<&iq`MY2i9ts4P3O=?}SiV)>uM;q8y9WqNm=qv-t5opWTyGpP_945TX9I>l3CFGVv z%K~Yut)-#^EFM+#s6%7;@9xFrELRiI^LoSLs`Ao_xT+k3WfroQ64N^6#A;#**U><8 zNto5688SnxQpSlha#)0c@k?W)<;Q4 zJSNK##V<3Dk<3=W4eCM*Azm|9sAaFAyiyfiQ;c1$WJ-d-)YgI=r@EETz&g%4A{k*s z1rg78M(Mkz9@%mx3M453Zy+&LKag4S{kBE^ZYg< z6j_xP5`ED9=5>`d_gfK*eWg)o(u^DN8qP-X+v+m7?Zh$mJ z`*XgT@hUMt%q(DD;N>>R7i1r%W!{?nG#5d((a7Zyi2-V)Y6&byBWRiCQAK>WeT3dm zFl%+I?jbVu{P%T{QfOfO` z;~M@OO@C^#G0_E}nfpgolV$SW?)_yAb*iy-jU0;~VC-P@wxHR|f8&)@Zf=!^01WD# z$3VHPbV%6PQG!gtFry+85|(nx>Umc^C(m@WvE@(&3c)wDZvea?PP}N9;4Yr9r|bcH z+U3C1g!mYiqiUXV;sPcz2ba$(om+vcQzdliqPZHthEr^jc*aoBE?~63+RU)J1 zS#4+DtDpcG$ST7wV?hsP7e-H8LYUwN9tgNB@S!TgzoUErU?kCuauTWUx%y$C*n55$ zCAHn=t7Y%gF-*xG+8xAYC>y2>4V{&*_V84NU)BUB@kspI7kSHic%Cn(6%Sc)-;=GP z>65c{tnCU&%gj+D&uNF#=IFuF!A6_b2i&fy(+fyCq28xnbZSuvmvnIOZ1bO7J0<~~ zsaEp^H(S*^qD77d4}_$x9pg$uag(vi4@9RC5&>L)#KP06RQUe3Qp5=5Xb+?Zp%e3HQp?ol zK{-<@3dE`;t$cFwpLQ0r$`+b2!WIK_R(kKSSdo=p`(=)@GF(g$MV%|-o+(WbLdPsD zk?;)cHnDZH{~mAEcm+cvZhi;Gu2oZ!{9U@!BEOp-?DwYyL zIbBc5r^?j5=g_Gt42q1n00$%ju#JQj5@th*x+;jOZzSBRG+bn^zx5Szd`Rn>455AK_v{`? zmC|r&sOvpgSap@$=j3XrBIrw>wkmXmx9u@g8 z%~}VCBCU?ZfLSCR5uizk*(|mV1U00Rd`wbO&NiBJM=71fP_OB2+3H5vicqL%AQ&Tf zZh8fzGUh@RNb0CEX>6_YRBg4um7N@tQH*5!yUGTt$fQ{!d(kodVbmWJ<1>TYg+yuf z2So2%G@(^VBw!XuS^<+rCXbvi`yhx}kX;tI&=9Ru9|J8+9!SsGurzMiV)C*3U?};mR`yPE_hRUm=mtv%b z8HIi?Md$rRkPuNj)ZPm8efJ2>-sc1j4)>9|lPn+1GSQ)jd}cjMd_2U&o`Kq|ach(o zPr94!K>>Azp-9z}z6@aOh|lB-7E;Im23v&cQAul*1pWqiS!vfo%Bh1^Wg@F}DR<4{ z@k!g>jF6j}n{}WAVBvvMg0(2TVlY&)Phm|hDVNhy(PBg=QNZUpXVSRA`)%5(L5Q2Y z3PH2y{;;QrQsp~|Qm~LscL|{Kv(OL+V;g1#`kfVY2$99_yPkS+gg7J@CJpA$P{ou@ z%JYPfH5(KKWrj=|#?AtKQWOJdZZX&ii*2+kM+NE1Nv;)2v*;znC9*yb;oTS$_B1zVet+M!7HdRL0y4gQe#^>#N};( zSSk*AZwW?0b5l{6ikE@z+X$Ah37lRG21_x9?>{3Ooe{5|8# zc{gNnWCLm_X&VQ;;pr0s*{MUyjSzFm7e|Y0zs?J+7PTEo#W_lbIP1jkPiROY(#r>7 zZ+lp05HOU>$rPeo*;JlNXRdTB&65~LLexxt1L(KbZ;7XvJb%hHmyv=>CO5P)vIc9S zoiJxhdSDp8LuDwogP%k<7d+icWFsqZQ`6ONQ~_w>!1N9Z~(#i>bD z1gG~KrYum42OR*oKwT@Al-9@J0}`0IPiG`Bb7*&5)Pbx)1v&pLx=42soZR+>y?_~+ zw3f{RAc{8SBd-IJ>n;r)N4i#y7Hl3qN;nmta3MtJ0zY|(Q&%CgfdiBIr`L zR`t%KOIO0=Cy8^b-cOKNUeddEQD8R;GJffwQAd-lRIQIll| z?`{k2owmarcB$tlDq-YxVVk-aEj>k^zx}dpMSAGEP^qK(!8?px>c9NeNb+bsOEwYU zlpLzaxOA;gS)zs*p(+$Kxs`cidG(I!Cu&)-XI2s#E{yKVbF=6Ja&y@sQSmlZqJ8II zur?jd%_L=;4U+z;SgDdXIh0!QdHXQq`GV<+O*%$^b59vG)N~4*5$zy?PX!c=q{p+LcI!h zv%g3|C?6PkCdnNOawwNqd~hcIE5tcF1;^Y*IDN&hLbJIW(AwJ*wlU`8jTuU$V6&6* zF0hbI!y9u4MzRL=bQCCp$4XjW?x7!&>V~x!YKY2XE)eA0ZQYf zbZI*^HqR2w!1GB7UMxfBCft7;;Tn?6czbUjRc42wTuaAj>4ipn zs+AX=SKlPH@BjiaO7j$1^@0M0J9o;^*tH)ed`uRC=Dvc|$qtxslI!8FzB6rsTdEV& zz|;E!s4M2nesIxL3pf!dLKN`{_I}+*1(hnaSN7m|TS+&7lFru_IMpU1Cp=e_O7|;{^Ag;4ZBW@%iiaNG>!X6-60DXv$VLOx1+_G@@y~&xbZfR_pm1- zHt{9{^eH(1??CEVd$>ByPocs!2Oq~f$EnVRTQyu1sWEn#H1y{~~;DKnV> zY;u}dLQeyK#jH?T2q^wgytkukm~Jx z8k)tlVFl}m-1$#*P?dkscqhKwHSKg`x4JI=o4wCz?4x<4F4@OSTgK8*@r-R5`I)4x zZFxI-NUd?tZdpNJ#>uuYR#3VQk~V1-P4pPT7g-EZF+OtJ5vE;Upojix3C3cQe~wmSvdG>kGp9K;2&g4BGYOKq>+Tr;*}*RLgzG~ar)Spta7l< z!VV|iE>T40VnKoJk`$UzflE*5T3R}u&m}o3|5`2GZzm?N zUc7YqLaC@pXBxHfi3@D5&AyQ4BHaNN2iu6zgJ;bz+m$At<#rh+28PD-Wn{$zyN6;k zlc#AFVW5`N<@x2On!Q^{Qh@2Sc^XG29;k24is8LKF zg1WYx3IuJX#~=1*zg!@BBR_DGZfajaAjla0kg{f&Xq1+yn^XDt+slZ6MJBT*IuMjO zJd%1n(}w*^{rQGO;PHb5l%Jgy1|b~gde7$piKnxv?nSzjVlK6@AWLUwL6SoQzYWr`YaZ0Z>UC)dC`nd@!;nyjzhHx8 z5B*LfoC%Xh!fh6B4*m1#*fZH`kN8DoVgpC!V;WD=_w?1Q3o2;M4gHYGgHps zQhBTZ37X@e*-F~Yi@6bj>yl0Z!6IAJ4I^(vGOBI^rShO4zPtm0Kyd~E=rNq7pAvWfPezDO3t*U ze?V>@p~_#bDUXwiBiIbEHxn@5kWi7)H7y(j&SPFT>Dr(l-+U$%{ezC%+=t~e<%ip? zjU!9OsxBkjC-+{QhIM@JUX~K@a#_}=vk2}*y2Rqk0&cxSZ&iot>Sw+cg%3zGLZZ9% zP;M~GHHqtQLVOm>CKJ=ZoE=gEp<0TtXOc_ca~UL(DwIk|03^0(!mOM&{0G&V2uS2f zIi*5g2qIhs?BQKvmqdn8C-g>6215r22V3<)Z;85U$cfV0pLldN7zmTBW^ioknG@)bAN(%rU~W+&#DykNgTo1hS;|j~NE*iH z?kdu{CI#C5Io`FgiWa5jl1ENwFWifCsa!Q{FVMX3Z2Hm!pIVC|DeU(oQ9>mdj$3<} zb6+1f4~5VZ$iV%j4C^4r;9r$SRQLP~1{lcd%x;URAO#X$YyGLWoZNhudyY7hRQA7{ zB+B9^Rd~SCI^OyW3C|TbpjJ=XZ9S*vQ~Aqy=ulVYTrE;FGLoXexuV;q5vAu=Q8jc% z9AESkY(UEzb(uzVR8IFi2nw!Z_g&dc%tAvOF!@iYx0DcIC$C|hiy7ll?TW*Hf`*3< z-f&}lI&x}oWN%0ej^q!$PiEwPe zQh#pKky9C!%tg8rmb7&Q9elfvplu3|3|VQbeIl8VhAiDjIZpV9h%DhzciCU6*`p)cPdJdgU_JK`GD@ zr@;FeZ^T^L%wx!l2)uu*)394zu{= zLc%W694TA+I?AP{kwk7P)O$Q}|MXk3NWPxmF(lOhik@j$m=yq$BK@SXiOdce>r~d> z)Nt5FZ`z(j>o{x#CNW2f*W@6|s((b}URh{5WyD3(_o0I#r2`*<-Jej1wG>zkFof&w z&M~Y@nH}{1fb=B%H3Hv$;QfAD!R5+1zD>%3o>?ujx#676=U1k$+P z!DJbv7ha+7w&39@Lp9@hhm!N)fuskcGz7M zB`9Bm>PR|jDmq3dUAE;ELeBJ8Pn-XQ&~Ir}N_)YkZ;m=*`k%QT89p{DFxi~^`JA`y4|-Y_64*;-`coLzEfZGU#xk_aIjV5O1N%cgVGTOx%3Ynt63P#Z5E zja!ytVz!);p0zr?uZLBvG4JW*3+wJr%@eY%!XMdjOj79~$z{UmyrTBEn9JmC;V!UD z$(=%NFl@M5QRa!j>^ej$$Sck8h@Z&yX#^V-nzja+ff3ST;G!d+skhWxSh8SAg@@b^ zQLBY*&w8-uU!`8?L9+jt#M%2#Y4jT;Hr;Q847sGw^_y3{w8XnPJeDc>PLOW)B3imyK>r3lhWaQPN*V=FnyT2I)QZRle=X6@xYk zNyoH5k@8doDEnCor2tui@~O?~K<)&0XS|MBmB@yq}I zQ*V{KS79+Jz8U*Bu&%cLO=@d8oyUzw`TS z2{t)9fH^uboQ=*CGnBUGU|hx$S-)2A!D{oMtOZtRy+d~k%QB$hpo_?K1oHFobR>rX>LgLD(0Vrm@k#>|RCbO8~M-Am=hL5@+DLT{s*9GFQk zA@Qr!k@F5igiSO|K`A*plXpH~j~db10AL5wDGUJPa&?zO&RiIvai<7g8U_jPZaUcP zy@pzcw7^9b2{?dDuwo*q`GH8(u)idYnSKxtA5Mi7Q)U*u<@FbSFB@x-#IsSwo{)>& zppdJ$K%Gq2zfN2oxiT?6`q8C{kA64Vb8T|s^5>VXBYqjC5@6pzf3fG%#FdFp$494n zesg8y((isVIyw2L4}UZArw`5Ve>^qzZ=)9midV1vM{hSbZFkTUDX{Ea3)=P=S4S_8 zjr5HE@!G`X^`1*tr|ff0^?-a>{3HL>^RE*Zr+)YO#b5OG{!_7ZG5P();z!rUKlMR` zTTB}Puer6y`bcC&#V%#|6ye827AZkqEd4o06r~P7H+l+DjG7-?)MMI|M6o1zSRQi@ zQ_1uJs`E0Oc38EbP>Ef|s|fZ1-n5X-Wal$!9_2bDtz3xxQ4vI*F_@Wl2Lz8B?2wdH zOD7nY?I_P;o8xvmWzJin7#}@ADHwp999QSf6)AFasU4<#H-M$WLjde)%Fz^$MLW)Q zR3peE7^u6Q{+FYHmdeN^VIK%Y? zN@A}@$3ML?Huc$u*G5MsKm6_F=;+kgNY-k>YFxeY7UuD7>{9RGFHT{XB)O8Rkcd%2 zJ*U)tjY3rpE+N-WYB0Bdb$vEI?aUz<9uXuH5C~`x*XVHdaWbhw2-it667g>7G z6@baMUL8TyB7w=(v?25P#`z-*x7*w={2m~g70vnY4m1tbuZ9}39>Ja+S6?rR)0-6BCy|oErIb^uwvq5&XxM>)+q%?k^3Tyt?TTLcn5nTbH%t@)`q4QIgi& zoj|E{aw;Upu*)M)WEhp<41i79T*(BUE-f4}^7xvec8tBv8O5jf z*QgW7(bEPP;Oc}($C=dE8VV~heQz#hOI6(uA{Ias*SI*`7#nQh{hU4+{tlmfCVh|) zXxX7SD&f?2b~H|`AXYDsNJ*P#VTekAa})1k&#J>4ZPT30wfzX3tq^gdKWExXP{J_r zBeXt^euPG9msRbGhXZ zc-UcssR+RFth~6eG5bqm8P9@m1FNt3Y;Yk>-p{r0bHo+@xk4E0#2PN`a84m#H%{5U zh=d@&_=Y>d?Jzq*OO#eTKWfLXAYe^vwCpW`G`aqO0(Sm*uwHkSN6H&<&;QelCjHW` zR0+pC?bcX13dA_*g6eO{I#8i*TmkT^^B^)|vXsGw;5iceU7D=4killzyd7Gssw9WF zON3JzYu7||ehiOnuCZukLu`C526p0eld(%swL))V%fMjEl+Aw|2H%nr$3yLr4}ub0F+v?Mv3 zvUdyARZ)j$paG z)X)qGEa_M#&Phc-mzF}AXP1wnlaB{-ZB2_LR;FUgQ2|_n=ouJ9?A7C*ktTYyr9}wB zUNc@`L|%4UBRRK4?_&=`e`A6GGk^!h%@>}?N^lxqihXRIh;>e1pOVjK-8XHgZ$Cu9 z_G^zI%YpInfus8lj}06jIy81-I0Og@RQZCy$o_ZVdG`KebJaCO%FK(z^=`HM?lT-g z%yku{B4_@y`>xCui?3<@r6N?O_>ndj za}<7m`8-~_?IS#vEZUV@h!zufLu)4zXSARrOM9rP?YRK4PaFxRTvtT1E=#G^c2>^< zXjip#4E^C{6z$t=%h)|BaL0hW3`ph!6VfVHu!r@sBcnm&g)bC4wOO%a*2aHH3~@*(CcQkQ_Q4726mSM>|- z)%@iNzJV9T{)!|hq3SuA)FQrCjzN30@C{snzZ-A3A7INh7RR$(WbKT~dY13t>Pm+o zuPN$TGHNS^p$vd_HtbKqqS4Khz3a8bmT&FBp0zR+w`bQ^&~(AH=bf7ndFIczW&4UP zJ8%@~#(l>JCI*j;o)|&sZK=o|+poXg!r=|04F({P3a19O*~Qi8PHkO902jgS_Lofb1D?t zJ(I-|9AW#Vc#G!Km8BR9%7S;8?ZAx3g%!`|%6QUl%5WTG^(~_%7btv;uu+evltir# z(q(1}$>vr+Nc>nSO`~@wduslUaRM<8lS2X@77zk+jo}hL(!6+%D;f5Jb^Rev8d4Gt zb6`Un=Cz!s;PjK$e}DzdD@^+Jz{hm#B|IqtW5l!fc=SiSa-V(7m~Y1a@bP@TF>MLL zv?pe#lIIS?=g;)ZqEy+6_riv^>-biT>LrOa2%le6Pemfm_9(uucl@K};%};G96-kG zJt--Jv;$G-C1@zXq&~U}`30W;p?x)#{gS-!>+AzYXv)JiVGtIMVljk@Wi zfh49Dn!ZdeHkPaHVQTXDc8AoUh!!f&gDJ(O;k+srsQ+UePV4)gbQ!~9G4Y}(ATAEB zWz>kP^Z;Z`tS{HlG404ObsbYu9V9j{E4#m%k?^NWqN0RD?o9GT0W1$clV}j#*2}9@ zH>_y=RQpxnLE!Gd7P(#vyx+nT_}mRnTpJSS_wzPaT z>~87I-bz(+{Aod3G>fA@(1;r=cN!}XD=(?8Enq=z))`i%I>V#z7TWPCGxQkW%OpO) zaE1eR`eAY7RN8(jG$SRMkc7IKV(?DpCDB@axER89Wfj47aZDm=-Z+6)nuY~Nx}GqU zVKiu3aY$=J;qg4Dso-oSn@aPUc+*)WlN#e0s`2gJD3ZZotDbp*Ultwx>^5#b3dt}h zcWbbBDu1S~HKb-1A|{@HBR1W;5Clyx>x}6Ti4Rc^}5q}50+Mw z0*)q_T$!iH!@9GG8a^Bz9T}R~XTih3f$`Ci6FYPwcmMH^TiC&9PBMAlk;vjxxuLR( zXHx+XIikPTC3J{|yKIO}-t)E7J^)Lh$^xr7*3rqE>4sEbhyfrfUJhqmvgl7P^14n6 zMJM+j`>77#YQtSs|G@k+amVXAoeVp6^@|WULgo@DdFG7s0>e?DARrS(ZmdgDa75hd z8&-kl8H<9sY|`-cX>`NGnY7%v_kA)KD3`u)UVTcPqPD-QqV0!^k!qA&e>m3I8@eI^ z2VByJ7EXHyT!LVFYN<{vm&Mf49Vn!M{BwKD6dl_U_~4PCwKSh56S|k;f;hNo8bWlVg*iIM9D>I=cB;C+)zbB3E&L8 zNVH=%PcE|rxEy7~nPgN)8{L?-91u}IPxGXusJOKNGc+Q~8zX2UC5E_=kZO-?+@pTM z_Th@Qw${#=aR_3-2hQ#|4`O6^Brv5Dp1>b|ze9HEoj-SP0ePfS+7tFWgX@I}dZr<2 zJfhcUCsh>KhLa>&S91mJFmb*8S#Kw_yp)a8H}e&ZegmaB^5_(kOCd)7r6*|dBySat z{6hrza3e09Cv0*XRB2?@LmKgzb2-yOjqh+b9TIqv036hp^KPE71cOh z@+T<8Z9h_*GTs#?lG_85)H)7!u z_j~Sa(3*74VQEN0GodoNbYtU@`j(gmkAV&PrVG$a=BLPeNR3Cg3p#JX*L^Ni(G!!2 z#s>$+=%pMVJ$^i~5IbZRe)CS-7TguEwZ3-M5w>y+J*ej@QsM3F8pVvL+a1;rlY)=Z zHP<}Z$92T3FdSZWwBBSHMv-+%{oD1@)lWt8HG+bz98wS$t}wNi-~c43rR|2P%m)Pq zo$_)TNc2}UoESRlI-x8BQ3j}Ni!5iTBqMa__`Zgej|cV%#~|)wgY|Dtw^o zd4A)hiXZ)EO8Z%cEgsqB=v5>$7z#nF8^5+sh6|nooauLb6nS{#L3GnK*pcT~0{eK( zUstArVghjf(o#2j9ez4FRZM_5g=TF`4;9Gdm{^7FZkG;;;-@Q21H-#! z3&nhmu-C4sK6L1+fU79g5oid>-U*x@s7?SHV?GWFkPea-q_;T<=@Xx`)73oFfIA!Y z+?LsJRl_NVr{=WH&J{Kd3>0LP^v@`?fMUfHG#F1&j?GW!VfvKPuq4BeD1s_WXl!1c zr$F)M901#e)u>l^<#igHfd^(tYBat;>kgO-ybl0u6d7C8nHRVd?)Xdr0dEOvO+>&J zz16j>N*Y8MzT)_Rs)77QG?9|YfEDJ!?7==IlU=VI`2fYvEs{5MsH5S5Hr5OU8x{?mIGk zV0e7+#JJBZ?65KU$KP#%nPBZnagc$JXJjQ3WM**$*5nhA4>Q3!YvEBYfCcD?cG^u|-|gK)7uNK7hY z09{Tbu#;7MBd!yL`+)YGnt)`WU=;oE3FlQ`XAHf~@OXtaG_NYb+urCMJ`OmPthhSLXDp}q1N%U@Q~ z8<=Yy%2bR3U@4V6`S8+!f;au8=^)pUC~UFt6816^@`24y0q!y@>Y+a#KQZ{h$k4unNckNLXZ#MG?LGgvg;GH(rro=PE#Vmlj1VQwtv{&D$ERvN z?;_f{whT&6>@*?U|7DVhE56UpY5wq4%Dl*r#mPF0KQtuo+!lHMtT0oURMBdKmMFaj zNAr>f3QQ~d%6Xq%(@~^V!QDOiH*^SQs@{#}DZd{{SVp*Qkqc_3R?rOUCtkRKT$Ry^ ze#AB_CjvTpIy<98z|rBOqvOojj~yI1I(l@6=|f#@Tj1UsPsYL2^?7-xAt^>GW(6nB zGSI7K?&pUW5}O|huKD9AE2PLQ*tbgD+D7zVHZs^tpr!=X9$(!{pBp>DDX|&xZyCCO z;Bpw>S0^76WGKNEAJDUmYIre~r>`wi^x}<1$;GA;G8XviTQfTp+>NOh9A zMc7gF1a;oB*ND1!&V}WcL-0xh!uOLrT}++FhD(jq$_$#jC2wkFhE9O%j%IPO^(foY zBRL16;OM&kW;}pno8kJKIYtkky3YOg0SFmVq3;fE;{bK`lDjc2q_I@-@5_D4G##GU zQ>ma*lk6W2(#n&(d>bkRwxZqEyn?T_wY$4G*y(AHjgAimH+zT9^VZ(25Pz@2u!g5I4N3 zWGoKB!pNQ?sgc-xe=!e*C0{Tuun;eN$A3M?pmi)=oy7jg4r8@Y*+sr_4V-(x0#i#c zvm6Ku-ckB6L4(5A3go$R5o%&C0{u*?(122WBa{s|8g1Of5Ks>zK5O*FZceUeS6L9C zL>Wkx(hNp#B3n;FOTn>`H_R+@3I@!Q59wLLnUj}MI=gSg+w38-d`pBOqgw8I;@qkjt(H=dos za#(f%lyTyg`bh=nwVta zM)HcCYN(@i*p3ZOG+z43zd6x0o)g6hiOvQ#hT4EmRGwr-B9(q~A#hVPOL>-y>0hY; zWC8j?>~yBmm@jT^&YahhHo}Fy6kbug1V$L^w zQW$cL>uoQXwi0D_pqp8)xU4(u8r=9on=;hA__$!6Sn?{g)vZwhwUEae_wi-A#toF8 zID_=1pe*MT+$FwhU;w14i`(;8B$T473U)5MkMu;_4HT?2%F7Ke*u4Xg>K0h)kM~B zNvB!jQ;g{YA@@NznTG=?+`t8?%2=V!df^isE0A%#C`8Vx?3}g8Y(ZHQ*5NwhdwRY@ zss9R~Tg;;;W!agTo_cDpTdk(mdDpBEml8NQqi1mR`C6FT%G;ts&)&`+TP9Ae%gwYr z>sf$*MB_DfZTLX&DlFV>g)-6a;K#HKy#Wk%XMZlYvF#)R^x6e1GuGgHk@mGr0M6Q$?1yy;fl#^eN zf1E6&PZw~0tgkTli!f7q)_E0f7tt=!W4pZm7+cL1BwakaiT_ceLRTrlQ^g6#w+wD2 zIa?sOh!&D&x!``s~EX|YH)T`18#ZarlLy>X0Ynf#ELPHB&?Dr^oBRQ$|z(u4T2gA zkd)Qi4WdUZRmO=Bf=3Mf?7ah*Ec#HDl_2C?lQezrv+ zI;$}PI`WKN67)6G1*!*}?^~zSf|{a9fJs2cWOFF~!q&-fU|{Yb9x?&7PnU2BB>Q_J zis&LoLXzbm48P<@tEbWt7ci)l5jVF@WcWoH$M_uUeG#GQiEIWMJw^+T-oCzE zhRHI)pIb)A#~X|KleMiena1Iuj5^^re}9YYSwW@A^wF314_lC01qo@PLUe98QCygn zdDVwU;`xI~I$==)=#DdZX}4dZ(+k|T4+FYRfQzQ{#^d#|u%%#m2(kaxe(5=KE_3eT0#U zAkix&+65rQE8LNhc*5STm&_&2yWZ4q30dFABQ&CmvzC($KAnu{TVy=AQV|5|BqBFg zrBZPbhQ*C+7f>5IU82HTzpiM7EoN#rmZz@=%O|WVLz+P0Vi-JHn;95iA4(zv@)n-x z=R{+vJ!~255))Rg4K@amf_C+opD9h}0)4%HWg#_BhD|lS-48$VeLc{7)IFSv@s^px zVw?HkD<0&hkzv_@C_d2O0VnMr3W~OZ?{~cWgIq|I13^_qpeB9PT)Lc;$M=@J*; z8liLgtkNlQVVSI5)SHIkd>&8@uUf__JQpKT(L^$5tKbN$CnUkIgW(LB5jd$B3XPCk zWPWqlB6^lU#Da1Nst{ER=w0P6CYP*4S@fsN_t(`Q6?af>?tA$k505mF#dozOAB4pd zQ{1FSdXL5jb=SjNhTv#r)P%%tA6>jtNRe~a_ z>b!4qY>CLBT~E}~JM%Q&$J6``c?&CSS9U1>G?s}%NvRtWOye|Pcqxrr*qJkFuHd^e zd==%lusZ42s6_Kh2elg@%SA6Zx8Lo$0z5Lu*$%B*X-{eV(G)`kW3MH0pDq6!SADSD6CZLDzd_gqhfRvOm zFy0)_m%A|)g`E#@V0Bx6WrN(0>6sZd+*}JflqhF99I%#LU-FD>;jl12sn%1Bn{K zFC`9)6AWAjslPS%)6nM)Ta+Y*IJBUxATeYz$ZKmN@U+=aKzBPfIlK1r>B5P~={#i( z?b+`#@hZf7k)adI3oUJ8jy&m!wy2B69DWv1fO5%&xJ*{D?A(bQ2CuJsZmbt5wJyS0 zd=?O*;TPDAPkV0ld~B(5p=~0B?_yV?qpq!SdZ(TjUBKU7TT2rM%5#;txU2TuV4D&O(wWDrYRrb&7GO|~(ku%ZCbx-S7qgi? zcaAmdxlcZea+k`@Sbydyp#inGi5!5YKm?RP+#!<>Byhiv)8gFa^8aBc|mex*2gB(|co7ASO7~x&M6=7BWnF%>f^doo5eGeDn7gurD1B zfsYL!);Ob6^iiW1ulvm6vlBqpe}EwoE5V`9_Mt(8#AI%JlePi)rfM@RV*D9KB)|wp z=6Lh?IENa4(E|uF1?WE!3zVbV`McFbo|!Kd;fq?pEr`-4}Nk$S0>n+fYN<;Y{^Jr81{!#>Dihys4C z9l*IC@j#$o&-&l0J4`_MjX8IxY-dnb)eORpmv?EMS}GAL0?ZdR1r*tx{b4;POl(pO zht+V(9GXjcSSnq3G#iBmzi&nr#(eH*SGDjxZ~%}&^Xe-G2-`Xj3X{peA_U3H-ZQ>3 z!!2!b2(Z2dxkP`12L^Hz0OeyP{jI#5DN^@3vL=Zm?n*v5qQ$2;NP0-@OQsSm?+d{~ zLgnB2L)p@DGa|*xYV*y3jT&SLs_ZSMm6yM6Ini##h>6_CbJ>0U1q>9 zv|F>5TbVueFGZyB$pL-mfrCIANT`D~>AAiPqgj`$o%rgz1|BHPA0J(wM5j;l_69yg zMakvwe9eu2Fgf9gb}qLh^aEHp3`|`Oe3qad2Oy3-#Uyns(=Nsnl86p`yGr)Bo3Nj6-9BT3> zR-Tyi`Xb6!9WEy4mx!e>$sI=c4^kF34s9YIn+VOt)yJ$_*~3v5MSAm)Nt9>L352U) z5y?OJH1x(ZE6=P-6aKC~i#{t}3?rXRIe*%RB&@38da=w`hPyCX9M94gjYZo~GB2+_ z*9dKB&&RT{_U#j&+2CyFT5tbo*OIT@USDGJ^)|P6*EhyrH<5#h#k1mMW8if#v^IPN zvD6V0g-5b3^;v@D`b}J^06;9|TrVgRakUa(E5&_&M$k2=rq8b9l+( zbY5j;9{=6%WBdE{Ae-;;`z&jx`|IPcaoHa@;MUh2MFX8PXSx#Qz;UeK2d#nWS;hpl zg|~Y8XgR>D_Q8CTzs!h+U({rH_^Rb9Dp^T#M;YE5yDyT70ja`O<7dT?azsx%NW)($ z#w*A$F+T-oNkS^qWj=pR_pR8-6KIVkY0O;0PW>sFc}jvV7i^JB-|_ZlhO*~CRdzdDEg*+^8$w-%+mp`x(r)yyoTU z`l==J+2!)eOG)qIVkGBrf)`e_;M~h-#|szt`_ark$ZN;S>gvLLB*)joINxLBYO(?E zv()63V*Rt(dv$w$=ll9*+wVoruBmq&7ss$`P7K&cs0SwpBMk%3dTr%?`xa!}>q~hL z{s6f#02mpVxFGZ{QV5t_luQ%Omt~dNIk%;uV56FQn<-!n*Anu;X9w()_id`@?-3t9 z-zWS5e%JQ$`u3Y1SPb6x&G)^hot2|{N@ZrsAl~yv{{2k@jMnT*|Fk?hXe}MHuBY@~ zuLX+KyC*&gGZh4vr>7~e^)aItDo>X*Y{2`PLv+szLVbX?2s=Z87D|kWg)GOC@vb?@o{5d-X&ewhx(kf;EA&a6`{q2ZqznMXqYCW8y|Mm6KMj;BhAB&l4--J6T`>;kj=ckL>PMkB|KyjQShO9MO-IJDp0 z60&Mgrj(Mh_VkW~j@1E6g#CBAHpbV*9P6;86S3LEceVZvHp-qIiPT{_5|T^ylmTo} zvqjxR;MJdGbawn2w!v6pbh+N7)pqDdlbC>&Aqrx-lwbg4wlXZzKZC4CH5y;kyTyzd z&vKasVN}D(A{y*>XEMKUZ{7B~b$$C~em(KK>-LG#ug4a;EPLL)s${k>7_+)K`dV*~@+x>F~V_>xp0#O z1ZHmDn|bid8cKIL)}^ERX8>r|KM-ojYJvX7%gc{A--FI;*;_N2$rf2lv+}X0M|^eq zD%WOht}{r`QcllnW3r|-J2>3@Z4GuEV{_lq)5~R27nXo}R}~mNm1$u!dCgITCOwvG zO3vQRz$4?c^gG)^u*Tmm=MIy|Z{XFR|J9gL5RKab(73XfSGrg@v6@ zc%|gXuD+R^+Kc{n`Qv=Ke(y#T)hhQYwpW^TPSzGTs}Ru}Wo5h>R&vopWq3eSYmi_) zKcYCA_h8hBhQ?^q>wKW}N&SuOTbd*`I&6;;dJ(}%3Tm_vC3HS{=y+;nM6=s+AxKi_ zP|>Y$F;$n`V<+dUSSQ4f8`mR$qjhJ5uUAh&#t~bb?0AC|%|?%z*6?Ym)~h|i^@*5b zJF~{WT#y3T2?oV9VaPkL3`s>d2MFe!uufx~;2dVuFE4#1bwIJ)Ov26((0qE0xkJjx z5+fI1wV&I(R3(hgdD)=jnMkDk^^&)rV4BuuksD*73rP(g4w zpnY(NIIz!)thX93-2aAV*^UtdCTVXG|DqNtuH2wK19J) z`2tE0<6*}aV#?6S9aMwv#Y?Wm)}x~BNWq~Ps=@jBgfP9A4lY4STBku&8`Ph69KQ@g zo5^#MbTks@U`FlL{*dVN;D3e`>p;yt8WA*vI8&G&aFVdSM^A(BXua*jgwKb-u27bt zS_QE6QlFmf4fjEa|AT=mT7jI@6$^`tUe$l(4Y=S7fR-<&^9!UyUTMEbARHdf+4<>t zv6l`PtfLXi)#GENU{IRP$c|n{Gahv;0!$?TKs~c~i*nnso+7D*>Vv~X zApMfQ(!sg=ns?kq!aW!ZOS9qVVx8+A=`uJ)#gur^qP3sN-l)IbF#UJ_Ngn!xc0Wwv zm>b@B0tRhKgYZg|#y~gH`|XXfc08bv1=((n9N&}(wFns1ZE~13EjwO;!JS}rzEnJX zH*vm;=|uUt+F2ieFl0RPM==^H7wDAa%D85win4ZZruNBo3aH!1`a?vOoDyVXGCpw| zh?IT}pRcrMX0mGhD-t>+WaYYtJNhk5K3N;pq}7;*!FajMv=q3bnxO6Wuo#*cp__;v zcs8u>`zMAyd|+jFb9d@!-Wl$>`OWDW#yFS;4^%zF?v)UGMMepUgsj=u-6UFnMWPxz zolrcOedK~G|NDahQHOjfPdG`{Cd_m$4={8G@Pel!R!!XF70m3mZYez)LtplHNlhU{Qx(V0s;7rd;0@c7fCX`T zkhsaoza}k*vLlCI&20}5BMgtTq!MP5FD$T|8Pxw8oYn;UJ$69gGj?X`_O%;Kk1gS9 z#X>^CKnVqG>4AVwv@r>k4j2gg;cJrzNlYg6^i6UQmmo0g*?Ah6H$(0>HoZ{&b^miI zWo7oEZ+DWn?cEFV(GOww@qFaP@eAP1L0WQ8-zxmBNrk@yug`&_0DypiARIgIm;QmK zQAu>0ND%gO&dYpb-A8>>+E@o?yf!GeiPJKJYmzDg9sh5VPh{S}M=s*J>BG2KDgY16 zACu9dY20R@r6^zWe4=Vzj(-eLLX-``ng&SUbYFNh{P`kS?Gk;7rkaqYtTREKcS55K zq%C*33PIXHf6f3pMjsNjK$dn@R%xq|5sPKY?V7gX#-A{J{yvOg5aHBQl&3_na zNEQfDCy4TnV(oAXWv@)JCPE3Eycw<_1rfSN{(HJ~I8YS7)$3P!Cm1VuZq$qIHUm=h zr9@2SL+06KHeDMN4}CoK0_URV>29yS*mg1?o6t^$*@ipgqlGxNFj~Zv?Qb+aP11XU z4x#1GhWLZz68XBJdI0OsD<+TwEP@DnQaquhCP*#LTp@edsLv0MDl2amo4A90?ddjm z7Fy0NEAg7iJ#DsQEZX_PdYr!$LF&Pb4S%{+Dxh>^D{!)G8$@W_@lZ|skqZid+Y3qC zpi%kLNJk&J!i^=X3u%7IKzFl$$yAS#kMG!i2cLPEhTHp`rtDXT(0juYYQOy2h@u&g zFp?)pm`S9ZI>aNe`pmXrurcm6jDu;7?~LOhQF_udWEL-!!?r@r+8Zs0WTin|%tY62 z%QMR}g!q$LBI0D?6I}qO^$Syou9#U+~Q89V-2VZ3t8GULVfcRSpaY@!Db6@yczg8LK}L z)2U1+#x0dd9wIT!J{aIxCyWG0s*4T09_DAbFcE|b1)JB8<5oEqWFtEH(@#6o$z>u} z$c%v><60rSLNU>Ej5tI&-kIHCJcMzMAmwSGE8S{03fK*Yy4I}DfCsK$K4L;+Ik0)Q z5Sco+_NLY;g&>&FO=PDca0NrrZ0G_dA-1TLF~obPR!%m{ z8@I=j^;+XZ+|(Jczy6J&fT|(%iKuks4AzxN=TbitsRyRW zIfhZQjn0!8-==>xq*(Xz3eamgQE? z6tNWzQ8&022X&qSTns8Kq65ptYnVAw(?!yd{DMR%$GE>i_Lu z_e=i@(Crn&-Ibs$u8pvLyDk~<63^`R))r&j%-ld?J>WM6@Y4;|o^d-w3DSPARyg_N z1z*?K@`m60RYocC$JfnHwJpHxN%)LB4rY{zBU`uUILLh>eYyv!LS#uo9#!C0G}qo* zsaE^JeUV4yJSDccWS#;STGP?b@|ogNcr)8Yd~sAMSyFb9`tS>a-cB*D_LEI#JQG0p5S0&K^mh;au*W)G_=TA;`QA z=;A7sVuLRbJ>;9T5=WO))b4|UZk{3e;&}@EiL3mp$&}zXfn3;WQ;X_rf^Q%Qb~aFC zwXw^*&nmROtYt24$UKodKQ{PU?R}l_su|lCDe_-#ztl!Q0XCyQ*08PxBlwJrP)7av zO=v5v31kM=!ij~%czOLz6$i>)f3dK6TIV0V3b->N3yJMyVSFA9#s_{g@G@~bJWd7H zxHUhwG`Tsqv^ad)_VB!VYRTET>B+e`b}^Shz^PGg4XeaIH8wUmH#fR8Js#|| zx@xJJX~EZj-ncb3Gfm{+q^IT`HZ`|8&|N)SIX0nGKdn~)6!u{UIB2?$PlX8EIC75d z*W(80NfmoW>Pfo)F_m~iQ!;zA!e!9L%>rC&R}XZqj-rbU{YY?~ZD|HJyHYz-E3AUV*bY+P@grYX zXK}V)k#Xx*B?Y9K{dbx0cbT$!zi@j5yGTi&z85`V9y_@Ygw?sOa0DB@yRliM_06B+ zpcRm*UwQz2ciZ_$D@;ceR;lNU+Ovt5T1{=lxk;-0;L_K~-g0w#3|vLLT*2#J7;2U# z`DFEoFa|DDstowTmiMp~6@S@E@VE2#z=I>v*kZW3H#DDdcl!H|+ry}{7ThnPs~&{toX^ z+v%|Jz-)B}nr(WzjG&0(5JB*ZsJ;(Jv4fo^mE3GjbWoBmfdE+@kCMsE3H}>O?Op{pmX5H$q+?T$qh8d!EFbL^b-^Txwm# z>cXKgZL*_G=s3T;R&CzxxArFa_(tgVj%sc1CifkT*j@~~7PFA@Gn1qPoNkVV`XBH! zMSEVdb?sIFm$q_J$UY^*cYGg29kMT!SxoOS=#w@@ot(Z7>8${$T8B53({(+FAmG4A z8APCuItHp^In=L@PF|;)@}7)T&zCE!rzw-Ux-D#ihXtdpo?gm34_Y`k%=uW%huA>pA2{9x4XFBs*zDjY+Evjbe&C z?#M(2yU_O_PlB<3uXVQ)0LG*7306g^+O{e@3F;RZ!jW*H;~!>%R_ zd9<-Jn8|11EKPodI~nm|F$=&fY~KY`%7_gsotc~3h_?k8Xh^*F zBVNcRG1)>PPXY;DX!gHTe_$tbkXFPad*C6uC(y`ykoWZ;_9lGMr9mP&2yzShE2H|k z$c#k@Rve~;DdiDdpVu_hNubDJ;DB@v46%d+Ec%lXI_TzO8vGc z1xiP+ww?HNZtlh~Be(K>KhMHl*2!DK-^3haw8NSyEC%v^$C#_DD%Tw`+=WcRRu60Q7R4>mk+anF6In^YrgF*;ma<;r( zj1}B%cYdE+W6mZ4s$wCFc?7PIMNx=)cx!8@eB2Diw zRxLc+O2(>CtXP?lVfu562c5GLCv%kYBm;X5Z7iaUQW52K+gPaw1NwbU=d_^Ysivc6 z%A049@4f>FQ%}ybew`o>yARZQ%TWaQuz?GPI@c<>+DmFXLrnPf07^^eM;)l zpD$3?6Fz2nkDeSI^NLRp>JXTg(YSv#@lux@U^S(^egtw=j_ZUAA4P&Te^&$!klt< z9b>z<5%zMf7bDcU;MsMZr>}ElTsX|1Q=Id?dxGTr3F|}sq9_KqpB3w#y*F}}lv0;0 z*?$=^1c(`^o4(RjEGFBbfU5L~OEm$Cg{^4jsh?t!0Yrw5pm#>Ps^~}k?QG7fu8p)i zAT`ShFu|~dgXByJEE5K+@@)aJ#E^otZD-jMAJUn`#^zuO!5pqJsh*5wU!qZSMSx)2 zCNI)kRsGV?x2v>wo=xJU);@U<}St`U)=(eo2>Zvw5pKwac zt!*syMAa#_RZt~h?`c>m!)D*-$cjgGJy#hXrRg=D>oU+kh)T1jBey$sx{gk@xqlc9KS5n2GL$Rp<@g>G~WgvzjqjU!5MT5w0c!lJvH$l@X%09MMu==8Hv_I4>A! zcXK15UYb+Z_72S1hyRwJ32F5jmAy86n>Mwtk3SPK00+6`3ZG^Prw<1uyRYLZW01>U!mqxnj?5%BQAs+{?f&bmt?ciif%$C zTGwPoQu}Mm$Ke?+_az70Rl!+-+|2uB6}iX5jn-?)Ux&E{PrgI{B%bL%4~_Tqk$XiZ-ZuTqb@JEl^`|F?I+T% z%!ip!Yxn4ShYqP%v_>^Ixz=bcdT7{Ba$|H|*RL8~6N1nA;9*L|5G?lTt93B*`%5Bo zJOQoEq9sZ#*9%f0BtOB)QBCE`dpojcuP1a>-<$Qxt+?JLT0T9fIumbi!|!kK{|2#q zcJ=wj_XEDH{)0o0{V#~+f3)u(=*sl2HrB%b$}`gaLF%H5 zH#UpOwCAe5bZ>_SoJO{LK&=DBG8`IJ6KbA3C z*P$zvQq@oH?}~CAD-E<#z3I|hl}*A8@hxiX_hDrfW@Zx|m_tZ7FZrTfZnrlH9NB+b zrJ`G*kxhq*`!#P;6LT;xpo6`$KEs``zwz$B@oHp!s#a(r4f4yU56{=KyrvHWJ|?y`irX26(|hB9qMP@) zQS9qf~vSpMyf#NQva`u+w6ke31mK>+{*fcVieLI5tw zsLp3Z008$10082D>7ARMqm`4niHWlly`H0qwG;h6PZ;UkZLI13alWae<&+_g<+D@% z1y8AFv19xW#?o;iF(xt0ocM=Qjj1EUL($-8W-UY`(AwDFcOKaG-^5yI(P?BJ;{HMW zxonxdJDevUCl@mhuS-6ylld%d90ezbmnvx>XC#uv=w{#VS08!YUdczkOm_z?ko42t zL2GLlH-Fz#{d^&e_(nqOH5x~C4d^9Y9oQxwhIVMFp-q1PQKlC}(sr{poH<{dD(Om= z`{A!YEzB7OajZ19M%LV#e7fHq937fJ9R$m~p3ht8p>aNLw-+`xC?Ix<`z-Ou-m(ML znJ+Cc#l~=5&*@7Hbg$V4T72EzIlhUr(8)dV3Qydmtm>r6D*%4g^X#cBOA=NQL@!t% znJx>V$c3=qQFTHdxVQRxG;HY2Lw4VF%y_g)f4)wx`#DCry4z|v;}0>?@ygoT$}Bi1 zXeb~Z*$r&Q?o&7U#@Ohh-$W%o20#*N`zxjTJ)A`P8T;bIG-$MA$kmoOFtp`9&S9Sm zWisr0E}{;338WvcsYn0b2M$u!%@Wq=KH)eLV;j*wB4<#3wl3G*^2LPNx+nKpYeKIq zu)?2OVy)5pS|XDD3V#aO#cyKj{>!b_S=zcX?H z&1xTU_fend(mDqmjlAK)y~ia7%kp?=N|p=0UQ?D$jq)cxZ(a^r&W>77jE{& zW^6snpMzMIexvHvlkFlQZe_3=I&fm9ILF)Tt6pT_w_^7p%&6L1Q~NVmx*>Vmu{nJb zHf#exd?)aPay1fs@%-kyZCK;BrWaRx$~(@^@K{4N_1smnI%>I}OgoOd_iourEla;* z9em9z(@@JjT_`^1_ZKo@^{0557|%@JNrN{YK4-PI%dhM6x~>yVIlRjKVXG}>E0%U& zzV5%b%aX`eK*kBuc%P)UW1I-m`mj~83V5aiQ*yz-SjIub(0qEM*NUBTSi(94{jp8T%YX)AgObLzPsYYc=PO^HabII9$v5J z{Q&AW400ZvtQ5ObG&)|+I8$=^X_C@_M=22dw+QRsQjZ+jCKnE;cXsOxk`4_Lg2iHV z7aTBW{SWJdL`w!umJV=wO_pR_@VYnUwN06I2TasgMogObT4Ce>VwE)dYZhQxbbn1Z z09SRFP4hc-2zrRg0;HL(fMG2^fn%BQ^m$C%?N}Xf&yi=kl_BJSbW-Q$ln~(PZ<$~t z#6-j3mHKF>Jn#tgD<6Vmwk z0vmVUKIo@Z@0Vg;h-BH>+Y$&(w@wWBH~~)<4IdeoceVLnBIO@9*guONYjB}Dp*3-hLUC=gK@TMpG%G?F^3YXQ=3wtIBRQq z2^#5qL()o@oR$t~cfTpf4TCx<@=W`G*tjTVeN-7ypfqNQIl+|#bAlJ~_y=5qi{)%z zB0=%$@hBPxcX=iK-7|ZjfDFaVF7wS@Eiq5e&u^q7VI`(3WbE~4CNnQm54lH@b%}U@ z^`RMjI~LY6oBI}=H9j9@*-GI3M4(9&$AG!=LP*?{i*#6L+{PeB z9dAtHi36OX?z~tNAt8Mr6Jb-;O<53UYQP}8Dk%xPQ)0dnju1vf4S{KS7Ij%m{&%|G zbYit}bvDpqVn>!aCBIidHAvGF<$QK7^~VbSBWmuKpjsR#z^|%2WlCffs97LWiQEcB zpmKdxrR0*UTn5PflCtpy5a%OJeiavI5uJGy9fgMr``4jNB`+prTwAxyK$q67V$IwF z>h*JViIh?=rZn@u#J&y?^^pue3|bk?6490)G04R&cPHQpSi-a?H(#iV514RZPUmM( zK&$@IrH#XevMZvkxJ}XT0wuIjBRC%Y1#Y|OQoLw!U@S9oe?-#w$df70+L?{6mK7qm z3dXK<>ZnA!WUs~?XatT04yaTI%hd@ARE%Jax!2{=8fQELNU?D>oAds{;Q8vG!-hhX zkd5(G&3i^|pe|SJvFrIH6)-%i&kgT8Rb}EGKRs|ZFZaofLuLUIt6@RLH=jk)o;iev z!n%fPOQ$D*pK1EKAzf^M1KQ3@Glz_KjN7$b^XYt6u~7Bd+tdrS0)$3#&XX}NCn13{ zy;PhO-1gn+jZ-x&DGeRJOEU2bG!@84GdXI zA>3|maGrE!eR8lq<-mS7YTeGWwEd(q-MYVAHlWFER=MktqQMuTN9AW2Vs3 z`*<-IyRK=ue{f{GMRp`wrecVrPn4I-0o#iv#~Pl!k1jYQV8q1+-bpQ(X-8QK?WA&b zEH6>A1$1FO3;A+z+hi3-k*Pw$cApiF;Kf6(w{R?t;uNlMBYEbk+|7=~fr=#zIap2V z!KFB(%AI27T=S+23HI=m!GJaVQ8w)c`ul`ChfauINFP*q(m3Z%)Xf2pL0@$>w@+{k zB8~cKO-Lewf}#S>F@aU;UcNrF-N7RuAMs?mn?bjPWI?C>Q5nUEsDyVUVL6LKeBVD5T45mme7<`-N<5;G0#R1+~x>Qq0+Qbx^zPhHO&8i}{ zC%C3!{W?&$9dNKFuiJ0>bVjtS#i4QV1yb6H+!5$rJMI8C6sW?s^p;o;dqirjn>uEw zTOO`8&fMhrK1b$S<6>wHuko;K%pR+hab!X9!p1uY5>lk_?*mVHDCTjt(vT zEtM=cKx4R^xW-5NE}}9=4?D><>|^EPt3=XBjm<)|8Z7A{HvO(9iVIRjyoTG2*BOfO z+#(aqgaUab4h$4zG5Cu^$27=Ux!05+M>>O`dhDl)Mr742?3%)(i6LcvYo*wz>35 zHP;5ZNoT_zQyLR%R1P<+8;)EX|Acfe=t8E6M7+3IpSS!?r{BxxGRjq|EOy0=1CNBtUuR*Cmv zV-EV$gSs_ksSi^wWQ{`QoX*wOzG4%F2a(mrP34nyx8oVkd02}`vA(tZ;NOBH&iJC7 zvyEw^QxsHg`RD{o$lDR(J`SktrR3>_mGgDP(!~xSX&t?RHfcOT5lO;8Zp@ulet}5} z40mm`TjWSB9)DV4FVI%c-Dp{!E+#{~tT!}R<||%D!ck~vMYFI}Vrx1!g4*!z#bm31 z%kfXv#GHJTXz%LFzM>7ns-M{Dh#8*Vv89a6iJ>sTYs~p8T6Q)}Fihv3k2lm?HvbH% zESSFhMgPj~TEuXvxyloIx=zh1l6WQ60(EoX$Rr3OvUr!js@y9&t{E%xZ8?+u&}mMz z)m}bRE;*LD-1fB4`J}G!FzGo`KwJq`Q)WIHj`uFVtDg{G5bw5qQvEn9?27j`*P5rf zO=}NXkO;VIa+MP+SgrEDq{NfgbfuM2P%w%1<7g--GnfI`x#_bHq$Q0uq^%|Hrg)JBoPUQ`T~s@1Ug?=w-Xq!ZZ9vArs3!+FY?Sp zCq+xrlaLy)rr_x_?sEM7gvK)2b$Zp_SF88p>Z4#)&;(Wi(ytjnE~J!8S|(;~*ah0B zBu7Lcwz95*G?O-IG9#jSt_mAOPHKo5x&ZMfScZ0e5F=fZv?yY0Y)h;RDL*xO<12$E zT1~9qf4bfN;$Eo$jVl^+fVd-@mfECq!<;K>0q4)I6||>N-+GIBuhRf_xPHkOtrMcA zYS`h1)t?QG2K%R%*?3#sphMSvaPJ#d=PN;WxvgjE5!uw(05o@f`Szj^1k)I8{-e%12jg(Sh1B8)k_R>frhqvZIL)T0a&ptxT1DX}_fqa`1Ee2YC@#lj{KmPXf+Kkv#Or z?gwSB9WoS55}G^t-BStwm@}*^<(`BTsG-Fe+4^mx_%Srw`K#VGV>EWobO;3wKS>tDUHGHacl8s7wTAl3$6FUtTUcuAA`Vn`tYY~dd^#FnZ_XGs2sXX3a6sS zTh_=na5j3%$ytBUg@4TrUr69AoW4iXGI9;{3qKDXnyz(z-Nj%^6 zp4q}2ozu$q8xyo_wMFt+JariY^(Hy3FN$u{3VKQMp7S=x2Oil_;a@3RFM)(6hfFNI za8}`+h)nB#@s{+js&0mrRX=i?^v{hLWTg~f0r@&Nx;y4}N#`iJ&d%avwMx0DN4uw- z>{lb8)(vo{m;AAW?8tm|VjzY+c=iI48@*}1X6`tc7xg)C%#7SB+ZkfZ~@-+4+?4 zeg9B*Kiv%xhboC-G#fW_-owbLN3w7*y~N6zi!Yjeo;i$WLlH8mKq3_ypGCAb!{is! zt^Sv&Q>Qw=n2ywd|CPN2KoZRmFwDjzk|FHV9YZ#sTN0W2uED!$vH-Q1(7|&@UE|vM ztoou<5PQFUYxOj_nZ6iPzKWz@^OAY92&gn11!-ze>a$oO?+&X63|{<mUsZbu zutuY;zxfOQ-`qyLp)a@ofB*otKQ2pzf4Pl}>}+gIY@MC{=_rbdpOGA3z!{l=dxj%? zq1%chEV#O5!f?OMg^zQwz@E&xmtx}*xvdR^-{t3i1H$8emsH&8(_2xA*MZqvIZS-~ z__&v0scIy$0+iK6Uf3fj6i!>DD;hKM^kIdbrf|s4AXQrqOS26sSSZ|yRIF%X*hWNQ zpeqi3ISOJ-MH6&luPtJ5Ptcg%JV0|imPGJAgCguywMGdsDsE$GR7pF1< z^&0Ea7AO5Otq}>AC4bmd--Qkqr9KU!=0I9z0==+42B#RbP8n0idBe_TLavbAEX)x-VdMc#KZBGlzm(_)7x#0(Q%yNv#8@b|79!PYeC z&W|wu-(J3usPqTsA93CO9EboAfW~%4@{V@)PV`20jwb(b>=ERH08-=v{QUj@?PMJ< zBRjx=5d0#(NqEoekU)i^AwdOr9w8}?qEOdxX<>=@E5%0K3HrH|U4N`@9M^sUX?$kh z)2`d&AyaO-9xl62eXbOUR3QpTzH(1c1++awZommeT@1Cvr689dSDl4YPR_9y(h6oX zO*GH}$a+^|9H|(((NPvTidM_NknJAsXvoBf#O1FARVAfYA?hwm%uFqa7^e%$&F?(C zu+!g%*8Jz>N-qG@42uK;oRNtXeLK3~qU(O(o=2mU!557^X9zE8Q){|7n^x3dMjOov zzs`+bKOuD|tL;9Ngz{W<82OK?bJ8{0A5`c-ZzHQWZMR!$=!M3rr9*BAT<7{5jF5X z48y-^nt{Fj|F3BO`*LL_@Yx|VVhp>HbPtSq9a!SJh)Z-}?|5-m#Q4FXQt8}j$|npq~^%)VSNJ_>@9G{4LgjPUU_uj)sarTp%o3vsFV(KDbc?Ga6>g@#*+D+orG_vkAT!=G9RAt+V9;ZcbW#Opc73k+ z)}T6Wo#CLHA}WNqhW>ar4t~Kob^xgFDDFlXTWFax#`05dVe=w{K3gUg6;R4SUS-)l z*jdWM(D`-_JuQ>M2IpO!EEXNBa@Xe%!8GJ)q`p0+Qr|~anz_kdTufYWJt9aCZkUn0 zjJJ`AU{e0D0xXt~c)o`KAe_d%wupHzlWK*2k`R!!vChJBa|FiW`9jB2#Z5&^&Whb% z2-yVN%@iM(9ya)+T2%QuT5A|C6isU(Sy;`mnTR^5SNh|WJmPEnW&G}HT|aFf55K1i zw;aa-ueXDvqZvCly4hku3P0Sxi>(jtnlfMUg010hUKC_SP;m>9*84j!gN&ru)<%x% zRP2GDqE2pe-jNO0HxpZ30`CMxZr4*FJzq!nSpSbr1q7l6_}|zGb*Dn^?;l-%1qA>= z{$J7lPfkn7&er*-L)26Dus3ni`G@k$Oa0#jnp7BwAAfF_{^>=b|1Sb^ll#=lVk0C@gk=6@RTKY^tG4I%v}E7q3U9MT*B z09pV*|3QTu8t}hSVft@8nf@Tu<^1py4)>p;C=>c86=!o38x#6}{{C}-@}JFMGS$KO zKl=updrenhVBkgZOr<;6fZ~$MoYZ1$cAG5RzSpplf#K*wMquJV2D5x1S~C)hQd5FU zia@iu*i1m5{6(1Y_bAu|TvNg5zCfSNLl{(g0&EbWxjl3P(c5_l1J~RF8;H{CL)VSk x>_F%R#`YaX28>1sx^~pM1X=shdq~=$HA{duD=_UdFz^B4JYZ5-4~z~51^`C`GwuKY From 25ea63735a55a9451a5f2bb99b50c68739354774 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Sun, 6 Jul 2025 20:23:12 +0900 Subject: [PATCH 049/339] =?UTF-8?q?[Fix]=20=ED=8C=8C=EC=9D=BC=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20=EB=B0=8F=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/PerfumeOnMe/spring/domain/ImageKeyword.java | 9 --------- src/main/java/PerfumeOnMe/spring/domain/Workshop.java | 10 ---------- .../spring/domain/mapping/RecommendedFragrance.java | 10 ---------- .../{ => fragranceInit}/FragranceImportService.java | 2 +- .../{ => fragranceInit}/FragranceRowProcessor.java | 2 +- 5 files changed, 2 insertions(+), 31 deletions(-) rename src/main/java/PerfumeOnMe/spring/service/{ => fragranceInit}/FragranceImportService.java (98%) rename src/main/java/PerfumeOnMe/spring/service/{ => fragranceInit}/FragranceRowProcessor.java (99%) diff --git a/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java b/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java index 5aca056..0329ecc 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java +++ b/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java @@ -1,14 +1,9 @@ package PerfumeOnMe.spring.domain; -import java.util.ArrayList; -import java.util.List; - import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; import PerfumeOnMe.spring.domain.base.BaseEntity; -import PerfumeOnMe.spring.domain.mapping.RecommendedFragrance; -import jakarta.persistence.CascadeType; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; @@ -16,7 +11,6 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -41,7 +35,4 @@ public class ImageKeyword extends BaseEntity { @JoinColumn(name = "user_id") private User user; - @OneToMany(mappedBy = "imageKeyword", cascade = CascadeType.ALL) - @Builder.Default - private List RecommendedFragranceList = new ArrayList<>(); } diff --git a/src/main/java/PerfumeOnMe/spring/domain/Workshop.java b/src/main/java/PerfumeOnMe/spring/domain/Workshop.java index a5a6361..45c8588 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Workshop.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Workshop.java @@ -1,14 +1,9 @@ package PerfumeOnMe.spring.domain; -import java.util.ArrayList; -import java.util.List; - import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; import PerfumeOnMe.spring.domain.base.BaseEntity; -import PerfumeOnMe.spring.domain.mapping.RecommendedFragrance; -import jakarta.persistence.CascadeType; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; @@ -16,7 +11,6 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -40,8 +34,4 @@ public class Workshop extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") private User user; - - @OneToMany(mappedBy = "workshop", cascade = CascadeType.ALL) - @Builder.Default - private List RecommendedFragranceList = new ArrayList<>(); } diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/RecommendedFragrance.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/RecommendedFragrance.java index cef59db..510be79 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/RecommendedFragrance.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/RecommendedFragrance.java @@ -4,9 +4,7 @@ import org.hibernate.annotations.DynamicUpdate; import PerfumeOnMe.spring.domain.Fragrance; -import PerfumeOnMe.spring.domain.ImageKeyword; import PerfumeOnMe.spring.domain.PBTI; -import PerfumeOnMe.spring.domain.Workshop; import PerfumeOnMe.spring.domain.base.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -43,16 +41,8 @@ public class RecommendedFragrance extends BaseEntity { @JoinColumn(name = "fragrance_id") private Fragrance fragrance; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "imageKeyword_id") - private ImageKeyword imageKeyword; - @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "pbti_id") private PBTI pbti; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "workshop_id") - private Workshop workshop; - } diff --git a/src/main/java/PerfumeOnMe/spring/service/FragranceImportService.java b/src/main/java/PerfumeOnMe/spring/service/fragranceInit/FragranceImportService.java similarity index 98% rename from src/main/java/PerfumeOnMe/spring/service/FragranceImportService.java rename to src/main/java/PerfumeOnMe/spring/service/fragranceInit/FragranceImportService.java index e8a2cc5..676dbf6 100644 --- a/src/main/java/PerfumeOnMe/spring/service/FragranceImportService.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragranceInit/FragranceImportService.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service; +package PerfumeOnMe.spring.service.fragranceInit; import java.io.InputStream; diff --git a/src/main/java/PerfumeOnMe/spring/service/FragranceRowProcessor.java b/src/main/java/PerfumeOnMe/spring/service/fragranceInit/FragranceRowProcessor.java similarity index 99% rename from src/main/java/PerfumeOnMe/spring/service/FragranceRowProcessor.java rename to src/main/java/PerfumeOnMe/spring/service/fragranceInit/FragranceRowProcessor.java index 566480f..03114bc 100644 --- a/src/main/java/PerfumeOnMe/spring/service/FragranceRowProcessor.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragranceInit/FragranceRowProcessor.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service; +package PerfumeOnMe.spring.service.fragranceInit; import java.util.Arrays; From 2dc98899329f4b8ca14d40402f418fd9ec01d93f Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Mon, 7 Jul 2025 17:42:32 +0900 Subject: [PATCH 050/339] =?UTF-8?q?[Fix]=20=ED=96=A5=EC=88=98=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=EC=97=90=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=ED=95=B4=EC=A3=BC=EA=B8=B0=20=EC=9C=84=ED=95=9C=20sho?= =?UTF-8?q?wBrand=20=ED=95=84=EB=93=9C=20=EC=83=9D=EC=84=B1=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/domain/enums/Brand.java | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Brand.java b/src/main/java/PerfumeOnMe/spring/domain/enums/Brand.java index dde1a46..7473bc7 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/Brand.java +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/Brand.java @@ -2,12 +2,19 @@ import java.util.Arrays; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor public enum Brand { - LOIVIE, - DIPTYQUE, - JOMALONE, - MAISON_MARGIELA, - FREDERIC_MALLE; + LOIVIE("로이비 (LOIVIE)"), + DIPTYQUE("딥티크 (DIPTYQUE)"), + JOMALONE("조 말론 (JOMALONE)"), + MAISON_MARGIELA("메종 마르지엘라 (MAISON MARGIELA)"), + FREDERIC_MALLE("프레데릭 말 (FREDERIC MALLE)"); + + private final String showBrand; public static Brand fromString(String input) { if (input == null) { @@ -22,4 +29,5 @@ public static Brand fromString(String input) { .findFirst() .orElseThrow(() -> new IllegalArgumentException("Unknown brand: " + input)); } + } From 130424971593e9f9dff5739c034df30fcb22565c Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Mon, 7 Jul 2025 17:42:50 +0900 Subject: [PATCH 051/339] =?UTF-8?q?[Fix]=20=ED=96=A5=EC=88=98=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=83=9D=EC=84=B1=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index f19bf72..268dbe7 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -26,6 +26,9 @@ public enum ErrorStatus implements BaseErrorCode { UNSUPPORTED_TYPE(HttpStatus.BAD_REQUEST, "DATA4002", "지원하지 않는 향수타입입니다."), PRICE_PARSING_ERROR(HttpStatus.BAD_REQUEST, "DATA4003", "가격 정보를 숫자로 변환할 수 없습니다."), + // 향수 상세 페이지 에러 + FRAGRANCE_NOT_FOUND(HttpStatus.BAD_REQUEST, "FRAGRANCE4001", "해당 ID에 해당하는 향수를 찾을 수 없습니다."), + // 예시,,, ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); From 0c02a4e6f1beef3b6aea33da28fa2f007f4c8cc7 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Mon, 7 Jul 2025 17:44:40 +0900 Subject: [PATCH 052/339] =?UTF-8?q?[Feature]=20=ED=96=A5=EC=88=98=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EA=B4=80=EB=A0=A8=20=EC=9D=91=EB=8B=B5=20DTO?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/fragrance/FragranceResponseDTO.java | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java new file mode 100644 index 0000000..4dbb8fd --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java @@ -0,0 +1,72 @@ +package PerfumeOnMe.spring.web.dto.fragrance; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class FragranceResponseDTO { + + // 향수 상세 페이지 응답 결과 + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class FragranceDetailResult { + private Long id; + private String brand; + private String name; + private List priceList; + private String keyword; + private String description; + private NoteDto note; + private FragranceTypeDto fragranceType; + private String gender; + private List locations; + private List seasons; + private String homePageUrl; + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class PriceDto { + private int mlcount; + private int price; + } + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class NoteDto { + private NoteSection top; + private NoteSection middle; + private NoteSection base; + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class NoteSection { + private List ingredients; + private String keywords; + private String description; + } + } + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class FragranceTypeDto { + private String lastingPower; + private int diffusionRange; + private String diffusionPower; + } + } +} + + From a56530740c58dd59e52502e02ec746517dd80c42 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Mon, 7 Jul 2025 17:51:34 +0900 Subject: [PATCH 053/339] =?UTF-8?q?[Feature]=20=EC=9D=91=EB=8B=B5=20DTO=20?= =?UTF-8?q?=EB=B3=80=ED=99=98=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/converter/FragranceConverter.java | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java diff --git a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java new file mode 100644 index 0000000..64710cf --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java @@ -0,0 +1,94 @@ +package PerfumeOnMe.spring.converter; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; + +import PerfumeOnMe.spring.domain.Fragrance; +import PerfumeOnMe.spring.domain.mapping.FragranceBaseNote; +import PerfumeOnMe.spring.domain.mapping.FragranceMiddleNote; +import PerfumeOnMe.spring.domain.mapping.FragrancePrice; +import PerfumeOnMe.spring.domain.mapping.FragranceTopNote; +import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; + +@Component +public class FragranceConverter { + + public FragranceResponseDTO.FragranceDetailResult toDetailDto(Fragrance fragrance) { + return FragranceResponseDTO.FragranceDetailResult.builder() + .id(fragrance.getId()) + .brand(fragrance.getBrand().getShowBrand()) + .name(fragrance.getName()) + .priceList(toPriceDtoList(fragrance.getFragrancePriceList())) + .keyword(fragrance.getKeyword()) + .description(fragrance.getDescription()) + .note(FragranceResponseDTO.FragranceDetailResult.NoteDto.builder() + .top(toNoteSection(fragrance.getTopNoteKeyword(), fragrance.getTopNoteDescription(), + extractTopNotes(fragrance.getFragranceTopNoteList()))) + .middle(toNoteSection(fragrance.getMiddleNoteKeyword(), fragrance.getMiddleNoteDescription(), + extractMiddleNotes(fragrance.getFragranceMiddleNoteList()))) + .base(toNoteSection(fragrance.getBaseNoteKeyword(), fragrance.getBaseNoteDescription(), + extractBaseNotes(fragrance.getFragranceBaseNoteList()))) + .build()) + .fragranceType(FragranceResponseDTO.FragranceDetailResult.FragranceTypeDto.builder() + .lastingPower(fragrance.getFragranceType().getLastingPower()) + .diffusionRange(fragrance.getFragranceType().getDiffusionRange()) + .diffusionPower(fragrance.getFragranceType().getDiffusionPower()) + .build()) + .gender(fragrance.getGender().getKoName()) + .locations(fragrance.getFragranceLocationList().stream() + .map(fl -> fl.getLocation().getName()) + .collect(Collectors.toList())) + .seasons(fragrance.getFragranceSeasonList().stream() + .map(fs -> fs.getSeason().getName()) + .collect(Collectors.toList())) + .homePageUrl(fragrance.getHomePageURL()) + .build(); + } + + // ml 당 가격 추출 + private List toPriceDtoList( + List fragrancePrices) { + return fragrancePrices.stream() + .map(fp -> FragranceResponseDTO.FragranceDetailResult.PriceDto.builder() + .mlcount(fp.getPrice().getMlCount()) + .price(fp.getPrice().getPrice()) + .build()) + .collect(Collectors.toList()); + } + + // 탑 노트 추출 + private List extractTopNotes(List fragranceNotes) { + return fragranceNotes.stream() + .map(noteMapping -> noteMapping.getNote().getName()) + .collect(Collectors.toList()); + } + + // 미들 노트 추출 + private List extractMiddleNotes(List fragranceNotes) { + return fragranceNotes.stream() + .map(noteMapping -> noteMapping.getNote().getName()) + .collect(Collectors.toList()); + } + + // 베이스 노트 추출 + private List extractBaseNotes(List fragranceNotes) { + return fragranceNotes.stream() + .map(noteMapping -> noteMapping.getNote().getName()) + .collect(Collectors.toList()); + } + + // 해당 노트, 노트 키워드, 노트 설명 저장. + private FragranceResponseDTO.FragranceDetailResult.NoteDto.NoteSection toNoteSection(String keyword, + String description, List ingredients) { + return FragranceResponseDTO.FragranceDetailResult.NoteDto.NoteSection.builder() + .keywords(keyword) + .description(description) + .ingredients(ingredients) + .build(); + } +} + + + From 627006d9c786439a8126109206f49fc87d8246dd Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Mon, 7 Jul 2025 17:53:45 +0900 Subject: [PATCH 054/339] =?UTF-8?q?[Feature]=20=ED=96=A5=EC=88=98=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradlew | 0 .../fragrance/FragranceRepositoryCustom.java | 5 +++ .../fragrance/FragranceRepositoryImpl.java | 25 ++++++++++++ .../service/fragrance/FragranceService.java | 8 ++++ .../fragrance/FragranceServiceImpl.java | 29 ++++++++++++++ .../web/controller/FragranceController.java | 40 +++++++++++++++++++ 6 files changed, 107 insertions(+) mode change 100644 => 100755 gradlew create mode 100644 src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java create mode 100644 src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java index 5d5591f..384e07d 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java @@ -1,4 +1,9 @@ package PerfumeOnMe.spring.repository.fragrance; +import java.util.Optional; + +import PerfumeOnMe.spring.domain.Fragrance; + public interface FragranceRepositoryCustom { + Optional findByIdWithAllDetails(Long id); } diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java index 70c269e..cbfaedb 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java @@ -1,10 +1,35 @@ package PerfumeOnMe.spring.repository.fragrance; +import java.util.Optional; + import org.springframework.stereotype.Repository; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import PerfumeOnMe.spring.domain.Fragrance; +import PerfumeOnMe.spring.domain.QFragrance; +import PerfumeOnMe.spring.domain.QPrice; +import PerfumeOnMe.spring.domain.mapping.QFragrancePrice; import lombok.RequiredArgsConstructor; @Repository @RequiredArgsConstructor public class FragranceRepositoryImpl implements FragranceRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Optional findByIdWithAllDetails(Long id) { + QFragrance f = QFragrance.fragrance; + QFragrancePrice fp = QFragrancePrice.fragrancePrice; + QPrice price = QPrice.price1; + + Fragrance result = queryFactory.selectFrom(f) + .leftJoin(f.fragrancePriceList, fp).fetchJoin() + .leftJoin(fp.price, price).fetchJoin() + .where(f.id.eq(id)) + .fetchOne(); + + return Optional.ofNullable(result); + } } diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java new file mode 100644 index 0000000..5dc3be1 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java @@ -0,0 +1,8 @@ +package PerfumeOnMe.spring.service.fragrance; + +import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; + +public interface FragranceService { + FragranceResponseDTO.FragranceDetailResult getFragranceDetail(Long fragranceId); +} + diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java new file mode 100644 index 0000000..86bfd53 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java @@ -0,0 +1,29 @@ +package PerfumeOnMe.spring.service.fragrance; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.converter.FragranceConverter; +import PerfumeOnMe.spring.domain.Fragrance; +import PerfumeOnMe.spring.repository.fragrance.FragranceRepository; +import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FragranceServiceImpl implements FragranceService { + + private final FragranceRepository fragranceRepository; + private final FragranceConverter fragranceConverter; + + @Override + public FragranceResponseDTO.FragranceDetailResult getFragranceDetail(Long fragranceId) { + Fragrance fragrance = fragranceRepository.findByIdWithAllDetails(fragranceId) + .orElseThrow(() -> new GeneralException(ErrorStatus.FRAGRANCE_NOT_FOUND)); + return fragranceConverter.toDetailDto(fragrance); + } + +} diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java new file mode 100644 index 0000000..ab59552 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java @@ -0,0 +1,40 @@ +package PerfumeOnMe.spring.web.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.service.fragrance.FragranceService; +import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/fragrances") +@Tag(name = "Fragrance", description = "향수 상세 조회 API") +public class FragranceController { + + private final FragranceService fragranceService; + + @GetMapping("/{fragranceId}") + @Operation( + summary = "향수 상세 조회", + description = "향수 ID로 상세 정보를 조회하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다", content = @Content(mediaType = "application/json", schema = @Schema(implementation = FragranceResponseDTO.FragranceDetailResult.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FRAGRANCE4001", description = "해당 ID에 해당하는 향수를 찾을 수 없습니다.") + } + ) + public ResponseEntity> getFragranceDetail( + @PathVariable("fragranceId") Long fragranceId) { + FragranceResponseDTO.FragranceDetailResult result = fragranceService.getFragranceDetail(fragranceId); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } +} From f5205e8f485b770ebd7cc16ab07b98b451c6a16e Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Mon, 7 Jul 2025 18:48:30 +0900 Subject: [PATCH 055/339] =?UTF-8?q?[Fix]=20DTO=20=EB=B3=80=ED=99=98=20?= =?UTF-8?q?=EC=A4=91=20Null=20=EA=B0=92=20=EB=B0=A9=EC=A7=80=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/converter/FragranceConverter.java | 40 +++++++++++++------ 1 file changed, 28 insertions(+), 12 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java index 64710cf..95b521d 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java @@ -1,14 +1,20 @@ package PerfumeOnMe.spring.converter; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; import org.springframework.stereotype.Component; import PerfumeOnMe.spring.domain.Fragrance; +import PerfumeOnMe.spring.domain.Location; +import PerfumeOnMe.spring.domain.Note; +import PerfumeOnMe.spring.domain.Season; import PerfumeOnMe.spring.domain.mapping.FragranceBaseNote; +import PerfumeOnMe.spring.domain.mapping.FragranceLocation; import PerfumeOnMe.spring.domain.mapping.FragranceMiddleNote; import PerfumeOnMe.spring.domain.mapping.FragrancePrice; +import PerfumeOnMe.spring.domain.mapping.FragranceSeason; import PerfumeOnMe.spring.domain.mapping.FragranceTopNote; import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; @@ -38,10 +44,14 @@ public FragranceResponseDTO.FragranceDetailResult toDetailDto(Fragrance fragranc .build()) .gender(fragrance.getGender().getKoName()) .locations(fragrance.getFragranceLocationList().stream() - .map(fl -> fl.getLocation().getName()) + .map(FragranceLocation::getLocation) + .filter(Objects::nonNull) + .map(Location::getName) .collect(Collectors.toList())) .seasons(fragrance.getFragranceSeasonList().stream() - .map(fs -> fs.getSeason().getName()) + .map(FragranceSeason::getSeason) + .filter(Objects::nonNull) + .map(Season::getName) .collect(Collectors.toList())) .homePageUrl(fragrance.getHomePageURL()) .build(); @@ -51,9 +61,12 @@ public FragranceResponseDTO.FragranceDetailResult toDetailDto(Fragrance fragranc private List toPriceDtoList( List fragrancePrices) { return fragrancePrices.stream() - .map(fp -> FragranceResponseDTO.FragranceDetailResult.PriceDto.builder() - .mlcount(fp.getPrice().getMlCount()) - .price(fp.getPrice().getPrice()) + .filter(Objects::nonNull) + .map(FragrancePrice::getPrice) + .filter(Objects::nonNull) + .map(price -> FragranceResponseDTO.FragranceDetailResult.PriceDto.builder() + .mlcount(price.getMlCount()) + .price(price.getPrice()) .build()) .collect(Collectors.toList()); } @@ -61,25 +74,31 @@ private List toPriceDtoList // 탑 노트 추출 private List extractTopNotes(List fragranceNotes) { return fragranceNotes.stream() - .map(noteMapping -> noteMapping.getNote().getName()) + .map(FragranceTopNote::getNote) + .filter(Objects::nonNull) + .map(Note::getName) .collect(Collectors.toList()); } // 미들 노트 추출 private List extractMiddleNotes(List fragranceNotes) { return fragranceNotes.stream() - .map(noteMapping -> noteMapping.getNote().getName()) + .map(FragranceMiddleNote::getNote) + .filter(Objects::nonNull) + .map(Note::getName) .collect(Collectors.toList()); } // 베이스 노트 추출 private List extractBaseNotes(List fragranceNotes) { return fragranceNotes.stream() - .map(noteMapping -> noteMapping.getNote().getName()) + .map(FragranceBaseNote::getNote) + .filter(Objects::nonNull) + .map(Note::getName) .collect(Collectors.toList()); } - // 해당 노트, 노트 키워드, 노트 설명 저장. + // 해당 노트, 노트 키워드, 노트 설명 저장 private FragranceResponseDTO.FragranceDetailResult.NoteDto.NoteSection toNoteSection(String keyword, String description, List ingredients) { return FragranceResponseDTO.FragranceDetailResult.NoteDto.NoteSection.builder() @@ -89,6 +108,3 @@ private FragranceResponseDTO.FragranceDetailResult.NoteDto.NoteSection toNoteSec .build(); } } - - - From 23bf14cc213d905aa33b0e3243f45e209af6630c Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Mon, 7 Jul 2025 19:26:04 +0900 Subject: [PATCH 056/339] =?UTF-8?q?[Fix]=20FragranceConverter=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20Fragrance?= =?UTF-8?q?ServiceImpl=20=EC=97=90=20=EB=B3=80=EA=B2=BD=20=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EB=B0=98=EC=98=81=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/converter/FragranceConverter.java | 15 ++++++--------- .../service/fragrance/FragranceServiceImpl.java | 3 +-- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java index 95b521d..593b56d 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java @@ -4,8 +4,6 @@ import java.util.Objects; import java.util.stream.Collectors; -import org.springframework.stereotype.Component; - import PerfumeOnMe.spring.domain.Fragrance; import PerfumeOnMe.spring.domain.Location; import PerfumeOnMe.spring.domain.Note; @@ -18,10 +16,9 @@ import PerfumeOnMe.spring.domain.mapping.FragranceTopNote; import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; -@Component public class FragranceConverter { - public FragranceResponseDTO.FragranceDetailResult toDetailDto(Fragrance fragrance) { + public static FragranceResponseDTO.FragranceDetailResult toDetailDto(Fragrance fragrance) { return FragranceResponseDTO.FragranceDetailResult.builder() .id(fragrance.getId()) .brand(fragrance.getBrand().getShowBrand()) @@ -58,7 +55,7 @@ public FragranceResponseDTO.FragranceDetailResult toDetailDto(Fragrance fragranc } // ml 당 가격 추출 - private List toPriceDtoList( + private static List toPriceDtoList( List fragrancePrices) { return fragrancePrices.stream() .filter(Objects::nonNull) @@ -72,7 +69,7 @@ private List toPriceDtoList } // 탑 노트 추출 - private List extractTopNotes(List fragranceNotes) { + private static List extractTopNotes(List fragranceNotes) { return fragranceNotes.stream() .map(FragranceTopNote::getNote) .filter(Objects::nonNull) @@ -81,7 +78,7 @@ private List extractTopNotes(List fragranceN } // 미들 노트 추출 - private List extractMiddleNotes(List fragranceNotes) { + private static List extractMiddleNotes(List fragranceNotes) { return fragranceNotes.stream() .map(FragranceMiddleNote::getNote) .filter(Objects::nonNull) @@ -90,7 +87,7 @@ private List extractMiddleNotes(List frag } // 베이스 노트 추출 - private List extractBaseNotes(List fragranceNotes) { + private static List extractBaseNotes(List fragranceNotes) { return fragranceNotes.stream() .map(FragranceBaseNote::getNote) .filter(Objects::nonNull) @@ -99,7 +96,7 @@ private List extractBaseNotes(List fragranc } // 해당 노트, 노트 키워드, 노트 설명 저장 - private FragranceResponseDTO.FragranceDetailResult.NoteDto.NoteSection toNoteSection(String keyword, + private static FragranceResponseDTO.FragranceDetailResult.NoteDto.NoteSection toNoteSection(String keyword, String description, List ingredients) { return FragranceResponseDTO.FragranceDetailResult.NoteDto.NoteSection.builder() .keywords(keyword) diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java index 86bfd53..af91b0d 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java @@ -17,13 +17,12 @@ public class FragranceServiceImpl implements FragranceService { private final FragranceRepository fragranceRepository; - private final FragranceConverter fragranceConverter; @Override public FragranceResponseDTO.FragranceDetailResult getFragranceDetail(Long fragranceId) { Fragrance fragrance = fragranceRepository.findByIdWithAllDetails(fragranceId) .orElseThrow(() -> new GeneralException(ErrorStatus.FRAGRANCE_NOT_FOUND)); - return fragranceConverter.toDetailDto(fragrance); + return FragranceConverter.toDetailDto(fragrance); } } From eb6eabc64c6a80db9e93688f9eabd465b1826d0e Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Mon, 7 Jul 2025 19:29:34 +0900 Subject: [PATCH 057/339] =?UTF-8?q?[Fix]=20Brand=20=EC=97=90=EC=84=9C=20fr?= =?UTF-8?q?omString()=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/domain/enums/Brand.java | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Brand.java b/src/main/java/PerfumeOnMe/spring/domain/enums/Brand.java index 7473bc7..b3dc37a 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/Brand.java +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/Brand.java @@ -1,7 +1,5 @@ package PerfumeOnMe.spring.domain.enums; -import java.util.Arrays; - import lombok.AllArgsConstructor; import lombok.Getter; @@ -15,19 +13,4 @@ public enum Brand { FREDERIC_MALLE("프레데릭 말 (FREDERIC MALLE)"); private final String showBrand; - - public static Brand fromString(String input) { - if (input == null) { - throw new IllegalArgumentException("브랜드명이 null입니다."); - } - - // "MAISON MARGIELA" → "MAISON_MARGIELA" - String normalized = input.trim().toUpperCase().replace(" ", "_"); - - return Arrays.stream(values()) - .filter(b -> b.name().equals(normalized)) - .findFirst() - .orElseThrow(() -> new IllegalArgumentException("Unknown brand: " + input)); - } - } From 19cfc506199f1f2f664564b239285af2e19264e2 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Mon, 7 Jul 2025 19:29:57 +0900 Subject: [PATCH 058/339] =?UTF-8?q?[Fix]=20@Tag=20=EC=9D=98=20description?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/web/controller/FragranceController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java index ab59552..2d05c92 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java @@ -18,7 +18,7 @@ @RestController @RequiredArgsConstructor @RequestMapping("/fragrances") -@Tag(name = "Fragrance", description = "향수 상세 조회 API") +@Tag(name = "Fragrance", description = "향수 조회 API") public class FragranceController { private final FragranceService fragranceService; From 9c888896ca4e9cb4b53bffd44aa9c73700269d47 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Mon, 7 Jul 2025 19:36:41 +0900 Subject: [PATCH 059/339] =?UTF-8?q?[Fix]=20BaseEntity=20=EC=83=81=EC=86=8D?= =?UTF-8?q?=20(#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/PerfumeOnMe/spring/domain/Terms.java | 3 ++- .../java/PerfumeOnMe/spring/domain/mapping/FragrancePrice.java | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/domain/Terms.java b/src/main/java/PerfumeOnMe/spring/domain/Terms.java index ed528c0..046ede6 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Terms.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Terms.java @@ -6,6 +6,7 @@ import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; +import PerfumeOnMe.spring.domain.base.BaseEntity; import PerfumeOnMe.spring.domain.mapping.UserTerms; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; @@ -29,7 +30,7 @@ @DynamicInsert @DynamicUpdate @Table(name = "terms") -public class Terms { +public class Terms extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragrancePrice.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragrancePrice.java index c542deb..abf48b1 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragrancePrice.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/FragrancePrice.java @@ -5,6 +5,7 @@ import PerfumeOnMe.spring.domain.Fragrance; import PerfumeOnMe.spring.domain.Price; +import PerfumeOnMe.spring.domain.base.BaseEntity; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; @@ -27,7 +28,7 @@ @DynamicInsert @DynamicUpdate @Table(name = "fragrance_prices") -public class FragrancePrice { +public class FragrancePrice extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; From 6eb4fa0be04e7540fa572096de9039077c9762c6 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 7 Jul 2025 23:16:11 +0900 Subject: [PATCH 060/339] =?UTF-8?q?[Feature]=20ErrorStatus=EC=97=90=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=B0=8F=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=B6=94=EA=B0=80=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apiPayload/code/status/ErrorStatus.java | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index f19bf72..9840fa6 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -19,7 +19,20 @@ public enum ErrorStatus implements BaseErrorCode { // 사용자 에러 LOGIN_ID_DUPLICATE(HttpStatus.BAD_REQUEST, "MEMBER4001", "이미 사용된 아이디입니다."), - PASSWORD_CONFIRM_FAIL(HttpStatus.BAD_REQUEST, "MEMBER4002", "비밀번호 확인을 실패했습니다."), + PASSWORD_NOT_MATCH(HttpStatus.BAD_REQUEST, "MEMBER4002", "비밀번호가 일치하지 않습니다."), + LOGIN_ID_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER4003", "해당 아이디를 가진 사용자가 존재하지 않습니다."), + LOGIN_PARSING_FAIL(HttpStatus.BAD_REQUEST, "MEMBER4004", "로그인 DTO 변환을 실패했습니다."), + LOGIN_UNKNOWN_ERROR(HttpStatus.BAD_REQUEST, "MEMBER4005", "로그인 중 알 수 없는 오류가 발생했습니다."), + + // 토큰 에러 + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "TOKEN4001", "유효하지 않은 토큰입니다."), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "TOKEN4002", "해당 리프레시 토큰이 존재하지 않습니다."), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "TOKEN4003", "만료된 토큰입니다."), + LOGOUT_ACCESS_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "TOKEN4004", "로그아웃한 액세스 토큰이 존재하지 않습니다."), + MALFORMED_TOKEN(HttpStatus.UNAUTHORIZED, "TOKEN4005", "토큰 구조가 잘못됐습니다."), + UNSUPPORTED_TOKEN(HttpStatus.UNAUTHORIZED, "TOKEN4006", "지원하지 않는 토큰 형식입니다."), + INVALID_SIGNATURE(HttpStatus.UNAUTHORIZED, "TOKEN4007", "토큰의 서명이 잘못됐습니다."), + TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "TOKEN4008", "토큰이 없습니다."), // 데이터시트 에러 UNSUPPORTED_BRAND(HttpStatus.BAD_REQUEST, "DATA4001", "지원하지 않는 브랜드입니다."), From 3ab871d609166c85bfb1a92ceb8ef1fbea9f79ff Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 7 Jul 2025 23:16:53 +0900 Subject: [PATCH 061/339] =?UTF-8?q?[Feature]=20Redis=20Key-Value=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20Configuration=20=EC=B6=94=EA=B0=80=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/config/RedisConfig.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/RedisConfig.java diff --git a/src/main/java/PerfumeOnMe/spring/config/RedisConfig.java b/src/main/java/PerfumeOnMe/spring/config/RedisConfig.java new file mode 100644 index 0000000..8aaafc1 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/RedisConfig.java @@ -0,0 +1,27 @@ +package PerfumeOnMe.spring.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; + +@Configuration +public class RedisConfig { + + /* + RedisConnectionFactory의 구현체로 LettuceConnectionFactory 사용 및 빈 등록 + */ + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(); + } + + /* + Key-Value를 String-String으로 저장하는 StringRedisTemplate 빈 등록 + */ + @Bean + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) { + return new StringRedisTemplate(redisConnectionFactory); + } +} From c669909e953ae7c5c50842927968f1c983b75f4a Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 7 Jul 2025 23:17:25 +0900 Subject: [PATCH 062/339] =?UTF-8?q?[Feature]=20=ED=95=84=ED=84=B0=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=86=B5=EC=9D=BC=EB=90=9C=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=9D=84=20=EB=B0=98=ED=99=98=ED=95=98=EA=B8=B0=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20static=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/apiPayload/ApiResponse.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/ApiResponse.java b/src/main/java/PerfumeOnMe/spring/apiPayload/ApiResponse.java index c20af40..93df12e 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/ApiResponse.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/ApiResponse.java @@ -1,11 +1,16 @@ package PerfumeOnMe.spring.apiPayload; +import java.io.IOException; + import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import com.fasterxml.jackson.databind.ObjectMapper; import PerfumeOnMe.spring.apiPayload.code.BaseCode; +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.code.status.SuccessStatus; +import jakarta.servlet.http.HttpServletResponse; import lombok.AllArgsConstructor; import lombok.Getter; @@ -14,6 +19,7 @@ @JsonPropertyOrder({"isSuccess", "code", "message", "result"}) public class ApiResponse { + private static final ObjectMapper mapper = new ObjectMapper(); @JsonProperty("isSuccess") private final Boolean isSuccess; private final String code; @@ -36,4 +42,18 @@ public static ApiResponse of(BaseCode code, T result) { public static ApiResponse onFailure(String code, String message, T data) { return new ApiResponse<>(false, code, message, data); } + + public static void setErrorResponse(HttpServletResponse response, + ErrorStatus code, Throwable e) throws IOException { + + // 응답 헤더 작성 + response.setCharacterEncoding("UTF-8"); + response.setContentType("application/json; charset=UTF-8"); + response.setStatus(code.getReasonHttpStatus().getHttpStatus().value()); + + // 응답 데이터 생성 및 작성 + ApiResponse res = ApiResponse + .onFailure(code.getCode(), code.getMessage(), null); // ???로 찍히는 에러 발생 중 + response.getWriter().write(mapper.writeValueAsString(res)); + } } From 9205dd2bfb21201c80cabfc4e2b97bfb071321f7 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 7 Jul 2025 23:18:14 +0900 Subject: [PATCH 063/339] =?UTF-8?q?[Fix]=20=EC=9E=90=EC=B2=B4=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EA=B0=80=EC=9E=85=EC=97=90=EC=84=9C=20passwordConfirm?= =?UTF-8?q?=EC=9D=84=20=EB=B0=9B=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/service/user/UserServiceImpl.java | 8 +------- .../PerfumeOnMe/spring/web/controller/UserController.java | 1 - .../PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java | 7 ------- 3 files changed, 1 insertion(+), 15 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java index 7c41176..28b2ffe 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java @@ -31,20 +31,14 @@ public UserResponseDTO.SignupResult signup(UserRequestDTO.Signup request) { String name = request.getName(); String loginId = request.getLoginId(); String password = request.getPassword(); - String passwordConfirm = request.getPasswordConfirm(); /* - 비즈니스 로직 검증 - 1. loginId 중복 확인 - 2. password와 passwordConfirm 일치 여부 확인 + 비즈니스 로직 검증 - loginId 중복 확인 */ Optional findUser = userRepository.findUserByLoginId(loginId); if (findUser.isPresent()) { throw new GeneralException(ErrorStatus.LOGIN_ID_DUPLICATE); } - if (!password.equals(passwordConfirm)) { - throw new GeneralException(ErrorStatus.PASSWORD_CONFIRM_FAIL); - } // 사용자 정보 엔티티 변환 및 DB 저장 User newUser = UserConverter diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java index 29f8cb7..8f9e6b7 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java @@ -37,7 +37,6 @@ public class UserController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON201", description = "리소스를 성공적으로 생성했습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserResponseDTO.SignupResult.class))), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4001", description = "이미 사용된 아이디입니다."), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4002", description = "비밀번호 확인을 실패했습니다."), } ) public ResponseEntity> signup( diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java index 192df02..ea94d4f 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java @@ -32,12 +32,5 @@ public static class Signup { message = "비밀번호는 영어 대소문자, 숫자, 특수문자(@$!%*?&#)만 허용되며, 공백 없이 8자 이상 20자 이하로 입력해주세요." ) private String password; - @NotBlank - @Schema(description = "사용자가 입력한 비밀번호 확인", example = "asdf1234") - @Pattern( - regexp = "^[A-Za-z\\d@$!%*?&#]{8,20}$", - message = "비밀번호는 영어 대소문자, 숫자, 특수문자(@$!%*?&#)만 허용되며, 공백 없이 8자 이상 20자 이하로 입력해주세요." - ) - private String passwordConfirm; } } From 28a09f1fe77f3722037de9fc8286cb0e86968c80 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 7 Jul 2025 23:20:08 +0900 Subject: [PATCH 064/339] =?UTF-8?q?[Feature]=20application.yml=EC=97=90=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=ED=95=9C=20token=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EA=B0=92=EC=9D=84=20=EB=B0=9B=EC=95=84=EC=98=A4=EB=8A=94=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/auth/dto/JwtProperties.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/auth/dto/JwtProperties.java diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/JwtProperties.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/JwtProperties.java new file mode 100644 index 0000000..c831c33 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/JwtProperties.java @@ -0,0 +1,24 @@ +package PerfumeOnMe.spring.config.security.auth.dto; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import lombok.Getter; +import lombok.Setter; + +@Component +@Getter +@Setter +@ConfigurationProperties("jwt.token") +public class JwtProperties { + + private String secretKey; + private Expiration expiration; + + @Getter + @Setter + public static class Expiration { + private Long access; + private Long refresh; + } +} From 061f92e73a21bfb3d2374708c19d8a1e99a4fd7b Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 7 Jul 2025 23:22:18 +0900 Subject: [PATCH 065/339] =?UTF-8?q?[Feature]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=EC=9A=94=EC=B2=AD=20=EB=B0=8F=20=EC=9D=91=EB=8B=B5=20DTO=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/auth/dto/AuthRequestDTO.java | 14 ++++++++++++++ .../security/auth/dto/AuthResponseDTO.java | 17 +++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthRequestDTO.java create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthResponseDTO.java diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthRequestDTO.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthRequestDTO.java new file mode 100644 index 0000000..aee81f8 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthRequestDTO.java @@ -0,0 +1,14 @@ +package PerfumeOnMe.spring.config.security.auth.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class AuthRequestDTO { + + @Getter + @NoArgsConstructor + public static class Login { + private String loginId; + private String password; + } +} diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthResponseDTO.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthResponseDTO.java new file mode 100644 index 0000000..a52f927 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthResponseDTO.java @@ -0,0 +1,17 @@ +package PerfumeOnMe.spring.config.security.auth.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class AuthResponseDTO { + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class RefreshToken { + private String refreshToken; + } +} From e19417c70cabca517eb03799408628b302e07d97 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 7 Jul 2025 23:22:53 +0900 Subject: [PATCH 066/339] =?UTF-8?q?[Feature]=20application.yml=EC=97=90=20?= =?UTF-8?q?Redis,=20Token,=20charset=20=EA=B4=80=EB=A0=A8=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EA=B0=92=20=EC=B6=94=EA=B0=80=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index bedcaec..b2711a6 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -19,4 +19,22 @@ spring: format_sql: true use_sql_comments: true default_batch_fetch_size: 1000 - show-sql: true \ No newline at end of file + show-sql: true + data: + redis: + host: # AWS Redis + port: 6379 + cache: + type: redis + jwt: + token: + secretKey: # secret key + expiration: + access: 7200000 # 2시간 + refresh: 1209600000 # 2주 +server: + servlet: + encoding: + charset: UTF-8 + force: true + enabled: true \ No newline at end of file From d5c26f9402bf499f897a7aa531839a7098db88be Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 7 Jul 2025 23:23:36 +0900 Subject: [PATCH 067/339] =?UTF-8?q?[Feature]=20SecurityConfig=EC=99=80?= =?UTF-8?q?=EC=9D=98=20=EB=B9=88=20=EC=88=9C=ED=99=98=20=EC=B0=B8=EC=A1=B0?= =?UTF-8?q?=EB=A5=BC=20=EB=A7=89=EA=B8=B0=20=EC=9C=84=ED=95=B4=20=ED=81=B4?= =?UTF-8?q?=EB=9E=98=EC=8A=A4=20=EB=B6=84=EB=A6=AC=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/AuthenticationManagerConfig.java | 31 +++++++++++++++++++ .../security/PasswordEncoderConfig.java | 16 ++++++++++ 2 files changed, 47 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/AuthenticationManagerConfig.java create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/PasswordEncoderConfig.java diff --git a/src/main/java/PerfumeOnMe/spring/config/security/AuthenticationManagerConfig.java b/src/main/java/PerfumeOnMe/spring/config/security/AuthenticationManagerConfig.java new file mode 100644 index 0000000..5147687 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/AuthenticationManagerConfig.java @@ -0,0 +1,31 @@ +package PerfumeOnMe.spring.config.security; + +import java.util.List; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.ProviderManager; + +import PerfumeOnMe.spring.config.security.auth.provider.CustomLoginAuthenticationProvider; +import PerfumeOnMe.spring.config.security.auth.provider.JwtAuthenticationProvider; +import lombok.RequiredArgsConstructor; + +@Configuration +@RequiredArgsConstructor +public class AuthenticationManagerConfig { + + private final JwtAuthenticationProvider jwtAuthenticationProvider; + private final CustomLoginAuthenticationProvider customLoginAuthenticationProvider; + + // AuthenticationManager 빈 등록 + @Bean + public AuthenticationManager authenticationManager() { + List providers = List.of( + jwtAuthenticationProvider, + customLoginAuthenticationProvider); + return new ProviderManager(providers); + } + +} diff --git a/src/main/java/PerfumeOnMe/spring/config/security/PasswordEncoderConfig.java b/src/main/java/PerfumeOnMe/spring/config/security/PasswordEncoderConfig.java new file mode 100644 index 0000000..616a8dc --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/PasswordEncoderConfig.java @@ -0,0 +1,16 @@ +package PerfumeOnMe.spring.config.security; + +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 PasswordEncoderConfig { + + // 문자열 암호화를 위한 PasswordEncoder 빈 등록 + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} From 6f7c44cf4bd8ae4fe9243393667c33626b4faa0a Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 7 Jul 2025 23:24:29 +0900 Subject: [PATCH 068/339] =?UTF-8?q?[Feature]=20BaseErrorCode=EC=97=90?= =?UTF-8?q?=EC=84=9C=20ErrorStatus=EB=A5=BC=20=EA=B0=80=EC=A0=B8=EC=98=A4?= =?UTF-8?q?=EB=8A=94=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/GeneralException.java | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/exception/GeneralException.java b/src/main/java/PerfumeOnMe/spring/apiPayload/exception/GeneralException.java index 0cb238a..3d97bf1 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/exception/GeneralException.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/exception/GeneralException.java @@ -2,6 +2,7 @@ import PerfumeOnMe.spring.apiPayload.code.BaseErrorCode; import PerfumeOnMe.spring.apiPayload.code.ErrorReasonDTO; +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import lombok.AllArgsConstructor; import lombok.Getter; @@ -9,13 +10,20 @@ @AllArgsConstructor public class GeneralException extends RuntimeException { - private BaseErrorCode code; + private BaseErrorCode code; - public ErrorReasonDTO getErrorReason() { - return this.code.getReason(); - } + public ErrorStatus getErrorStatus() { + if (this.code instanceof ErrorStatus) { + return (ErrorStatus)this.code; + } + return null; + } - public ErrorReasonDTO getErrorReasonHttpStatus(){ - return this.code.getReasonHttpStatus(); - } + public ErrorReasonDTO getErrorReason() { + return this.code.getReason(); + } + + public ErrorReasonDTO getErrorReasonHttpStatus() { + return this.code.getReasonHttpStatus(); + } } From a1f8c7f2e928e2add157a0336e80a522771588b1 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 7 Jul 2025 23:25:59 +0900 Subject: [PATCH 069/339] =?UTF-8?q?[Feature]=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC(401=20=EC=97=90=EB=9F=AC)=20=ED=95=B8?= =?UTF-8?q?=EB=93=A4=EB=9F=AC=20=EC=B6=94=EA=B0=80=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../handler/JwtAuthenticationEntryPoint.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/auth/handler/JwtAuthenticationEntryPoint.java diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/handler/JwtAuthenticationEntryPoint.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/handler/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..2975bb9 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/handler/JwtAuthenticationEntryPoint.java @@ -0,0 +1,39 @@ +package PerfumeOnMe.spring.config.security.auth.handler; + +import java.io.IOException; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/* +액세스 토큰(인증)이 필요한 API을 그냥 호출한 경우 터지는 예외를 핸들링하는 클래스 + */ +@Component +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + + // 응답 헤더 설정 + response.setCharacterEncoding("UTF-8"); + response.setContentType("application/json; charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + + // 응답 데이터 생성 및 작성 + ApiResponse res = ApiResponse.onFailure(ErrorStatus._UNAUTHORIZED.getCode(), + ErrorStatus._UNAUTHORIZED.getMessage(), "액세스 토큰을 입력해 주세요."); + response.getWriter().write(mapper.writeValueAsString(res)); + } +} From 75ec76d008c88a2a1f53542b0e3e900756c35b5a Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 7 Jul 2025 23:26:18 +0900 Subject: [PATCH 070/339] =?UTF-8?q?[Feature]=20=EC=A0=91=EA=B7=BC=20?= =?UTF-8?q?=EA=B1=B0=EB=B6=80(403=20=EC=97=90=EB=9F=AC)=20=ED=95=B8?= =?UTF-8?q?=EB=93=A4=EB=9F=AC=20=EC=B6=94=EA=B0=80=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/handler/JwtAccessDeniedHandler.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/auth/handler/JwtAccessDeniedHandler.java diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/handler/JwtAccessDeniedHandler.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/handler/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..c02a51f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/handler/JwtAccessDeniedHandler.java @@ -0,0 +1,39 @@ +package PerfumeOnMe.spring.config.security.auth.handler; + +import java.io.IOException; + +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/* +사용자의 권한으로 접근할 수 없는 API를 호출한 경우 터지는 예외를 핸들링하는 클래스 + */ +@Component +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException, ServletException { + + // 응답 헤더 작성 + response.setCharacterEncoding("UTF-8"); + response.setContentType("application/json; charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + + // 응답 데이터 생성 및 작성 + ApiResponse res = ApiResponse.onFailure(ErrorStatus._FORBIDDEN.getCode(), + ErrorStatus._FORBIDDEN.getMessage(), "권한이 없습니다."); + response.getWriter().write(mapper.writeValueAsString(res)); + } +} From 1e34c81e56b3b9074b5965656c01aa13ce100f46 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 7 Jul 2025 23:27:00 +0900 Subject: [PATCH 071/339] =?UTF-8?q?[Feature]=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EC=8B=9D=EB=B3=84=20=EC=A0=95=EB=B3=B4(UserDetails)?= =?UTF-8?q?=EB=A5=BC=20=EB=8B=A4=EB=A3=A8=EB=8A=94=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/userDetails/CustomUserDetails.java | 65 +++++++++++++++++++ .../userDetails/CustomUserDetailsService.java | 29 +++++++++ 2 files changed, 94 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/auth/userDetails/CustomUserDetails.java create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/auth/userDetails/CustomUserDetailsService.java diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/userDetails/CustomUserDetails.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/userDetails/CustomUserDetails.java new file mode 100644 index 0000000..b5222f6 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/userDetails/CustomUserDetails.java @@ -0,0 +1,65 @@ +package PerfumeOnMe.spring.config.security.auth.userDetails; + +import java.util.Collection; +import java.util.Collections; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import lombok.RequiredArgsConstructor; + +/* +사용자 식별 정보 + */ +@RequiredArgsConstructor +public class CustomUserDetails implements UserDetails { + + private final Long userId; + private final String name; + private final String loginId; + private final String password; + + @Override + public boolean isAccountNonExpired() { + return UserDetails.super.isAccountNonExpired(); + } + + @Override + public boolean isAccountNonLocked() { + return UserDetails.super.isAccountNonLocked(); + } + + @Override + public boolean isCredentialsNonExpired() { + return UserDetails.super.isCredentialsNonExpired(); + } + + @Override + public boolean isEnabled() { + return UserDetails.super.isEnabled(); + } + + @Override + public Collection getAuthorities() { + return Collections.singletonList(new SimpleGrantedAuthority("USER")); + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + return loginId; + } + + public Long getUserId() { + return userId; + } + + public String getName() { + return name; + } +} diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/userDetails/CustomUserDetailsService.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/userDetails/CustomUserDetailsService.java new file mode 100644 index 0000000..334d64d --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/userDetails/CustomUserDetailsService.java @@ -0,0 +1,29 @@ +package PerfumeOnMe.spring.config.security.auth.userDetails; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import PerfumeOnMe.spring.domain.User; +import PerfumeOnMe.spring.repository.user.UserRepository; +import lombok.RequiredArgsConstructor; + +/* +사용자 식별 정보를 조회하는 서비스 + */ +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundException { + User user = userRepository.findUserByLoginId(loginId) + .orElseThrow(() -> new UsernameNotFoundException("해당 사용자를 찾을 수 없습니다.")); + + return new CustomUserDetails( + user.getId(), user.getName(), user.getLoginId(), user.getPassword()); + } +} From fd5fdebbfa5626b2c3bc6bb8b8f7cd951f9fcaab Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 7 Jul 2025 23:32:05 +0900 Subject: [PATCH 072/339] =?UTF-8?q?[Feature]=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1,=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D,=20=EC=B6=94=EC=B6=9C=EC=9D=84=20=EB=8B=B4=EB=8B=B9?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/provider/JwtTokenProvider.java | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtTokenProvider.java diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtTokenProvider.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtTokenProvider.java new file mode 100644 index 0000000..018a0f0 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtTokenProvider.java @@ -0,0 +1,131 @@ +package PerfumeOnMe.spring.config.security.auth.provider; + +import java.security.Key; +import java.util.Date; + +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.config.security.auth.dto.JwtProperties; +import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.UnsupportedJwtException; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +/* +토큰 추출, 생성, 유효성 검증을 담당하는 클래스 + */ +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + private final JwtProperties jwtProperties; + private Key signingKey; + + // 토큰 서명에 쓰이는 key 생성 + @PostConstruct + private void init() { + this.signingKey = Keys.hmacShaKeyFor(jwtProperties.getSecretKey().getBytes()); + } + + // 액세스 토큰 생성 - 2시간 + public String createAccessToken(Authentication authentication) { + CustomUserDetails userDetails = (CustomUserDetails)authentication.getPrincipal(); + String loginId = userDetails.getUsername(); + Long userId = userDetails.getUserId(); + String name = userDetails.getName(); + + return Jwts.builder() + .setSubject(loginId) + .claim("userId", userId) + .claim("name", name) + .setIssuedAt(new Date()) + .setExpiration(new Date( + System.currentTimeMillis() + jwtProperties.getExpiration().getAccess())) + .signWith(signingKey) + .compact(); + } + + // 리프레시 토큰 생성 - 2주 + public String createRefreshToken(Authentication authentication) { + CustomUserDetails userDetails = (CustomUserDetails)authentication.getPrincipal(); + String loginId = userDetails.getUsername(); + Long userId = userDetails.getUserId(); + + return Jwts.builder() + .setSubject(loginId) + .claim("userId", userId) + .setIssuedAt(new Date()) + .setExpiration(new Date( + System.currentTimeMillis() + jwtProperties.getExpiration().getRefresh())) + .signWith(signingKey) + .compact(); + } + + // 토큰 유효성 검증 + public boolean validateToken(String token) { + try { + Jwts.parserBuilder() + .setSigningKey(signingKey) + .build() + .parseClaimsJws(token); + return true; + } catch (ExpiredJwtException e) { + throw new GeneralException(ErrorStatus.EXPIRED_TOKEN); + } catch (MalformedJwtException e) { + throw new GeneralException(ErrorStatus.MALFORMED_TOKEN); + } catch (UnsupportedJwtException e) { + throw new GeneralException(ErrorStatus.UNSUPPORTED_TOKEN); + } catch (SignatureException e) { + throw new GeneralException(ErrorStatus.INVALID_SIGNATURE); + } catch (IllegalArgumentException e) { + throw new GeneralException(ErrorStatus.TOKEN_NOT_FOUND); + } catch (JwtException e) { + throw new GeneralException(ErrorStatus.INVALID_TOKEN); + } + } + + // 헤더에서 토큰 추출 + public String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader("Authorization"); + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { + return bearerToken.substring(7); + } + return null; + } + + // 토큰에서 Subject 추출 + public String getSubject(String token) { + validateToken(token); + + return Jwts.parserBuilder() + .setSigningKey(signingKey) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject(); + } + + // 토큰에서 Expiration 추출 + public Long getExpiration(String token) { + validateToken(token); + + return Jwts.parserBuilder() + .setSigningKey(signingKey) + .build() + .parseClaimsJws(token) + .getBody() + .getExpiration() + .getTime(); + } +} From f6b028bd43dd1a9862d8df7038c18085a4bde3b2 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 7 Jul 2025 23:33:19 +0900 Subject: [PATCH 073/339] =?UTF-8?q?[Feature]=20=EC=9D=BC=EB=B0=98=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=EC=9D=84=20=EC=A7=84=ED=96=89=ED=95=98=EA=B3=A0,=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=EC=9D=84=20=EB=B6=80=EC=97=AC=ED=95=98=EB=8A=94=20Aut?= =?UTF-8?q?henticationProvider=20=EC=B6=94=EA=B0=80=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CustomLoginAuthenticationProvider.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/auth/provider/CustomLoginAuthenticationProvider.java diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/CustomLoginAuthenticationProvider.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/CustomLoginAuthenticationProvider.java new file mode 100644 index 0000000..b9a68d8 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/CustomLoginAuthenticationProvider.java @@ -0,0 +1,40 @@ +package PerfumeOnMe.spring.config.security.auth.provider; + +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class CustomLoginAuthenticationProvider implements AuthenticationProvider { + + private final UserDetailsService userDetailsService; + private final PasswordEncoder passwordEncoder; + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + String loginId = authentication.getName(); + String password = authentication.getCredentials().toString(); + + UserDetails userDetails = userDetailsService.loadUserByUsername(loginId); + if (!passwordEncoder.matches(password, userDetails.getPassword())) { + throw new BadCredentialsException("비밀번호가 일치하지 않습니다."); + } + + return UsernamePasswordAuthenticationToken + .authenticated(userDetails, null, userDetails.getAuthorities()); + } + + @Override + public boolean supports(Class authentication) { + return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication); + } +} From 7461fa5f58cb0a82a2db6b059f15b27f8d592e0e Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 7 Jul 2025 23:37:54 +0900 Subject: [PATCH 074/339] =?UTF-8?q?[Feature]=20JwtAuthenticationFilter?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B0=9C=EC=83=9D=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=EB=A5=BC=20=EC=9E=A1=EC=95=84=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC=ED=95=B4=EB=91=94=20API=20=EC=9D=91=EB=8B=B5=EC=97=90?= =?UTF-8?q?=20=EB=A7=9E=EA=B2=8C=20=EC=B2=98=EB=A6=AC=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../filter/JwtExceptionHandlerFilter.java | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtExceptionHandlerFilter.java diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtExceptionHandlerFilter.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtExceptionHandlerFilter.java new file mode 100644 index 0000000..bbb86cf --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtExceptionHandlerFilter.java @@ -0,0 +1,45 @@ +package PerfumeOnMe.spring.config.security.auth.filter; + +import static PerfumeOnMe.spring.apiPayload.ApiResponse.*; + +import java.io.IOException; + +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +/* +Security Filter에서 발생하는 예외를 잡아 통일해둔 API 응답에 맞게 처리하는 클래스 + */ +@Component +public class JwtExceptionHandlerFilter extends OncePerRequestFilter { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + try { + filterChain.doFilter(request, response); + } catch (GeneralException e) { + setErrorResponse(response, e.getErrorStatus(), e); + } catch (UsernameNotFoundException e) { + setErrorResponse(response, ErrorStatus.LOGIN_ID_NOT_FOUND, e); + } catch (BadCredentialsException e) { + setErrorResponse(response, ErrorStatus.PASSWORD_NOT_MATCH, e); + } catch (Exception e) { + setErrorResponse(response, ErrorStatus._INTERNAL_SERVER_ERROR, e); + } + } +} From ddc20b454bf7439757c9e3b8377f090da83bbc7d Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 7 Jul 2025 23:58:59 +0900 Subject: [PATCH 075/339] =?UTF-8?q?[Feature]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=EA=B3=BC=20=EC=9D=B8=EC=A6=9D=20=EA=B5=AC=EC=A1=B0=EB=A5=BC=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=ED=95=98=EA=B8=B0=20=EC=9C=84=ED=95=9C=20Jwt?= =?UTF-8?q?AuthenticationToken=20=EC=B6=94=EA=B0=80=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/token/JwtAuthenticationToken.java | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/auth/token/JwtAuthenticationToken.java diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/token/JwtAuthenticationToken.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/token/JwtAuthenticationToken.java new file mode 100644 index 0000000..2d0ad46 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/token/JwtAuthenticationToken.java @@ -0,0 +1,37 @@ +package PerfumeOnMe.spring.config.security.auth.token; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.userdetails.UserDetails; + +import lombok.Getter; + +@Getter +public class JwtAuthenticationToken extends UsernamePasswordAuthenticationToken { + + private final Object principal; + private final Object credentials; + + public JwtAuthenticationToken(Object principal, Object credentials) { + super(principal, credentials); + this.principal = principal; + this.credentials = credentials; + setAuthenticated(false); + } + + public JwtAuthenticationToken(UserDetails userDetails) { + super(userDetails, null); + this.principal = userDetails; + this.credentials = null; + setAuthenticated(true); + } + + @Override + public Object getCredentials() { + return super.getCredentials(); + } + + @Override + public Object getPrincipal() { + return super.getPrincipal(); + } +} From 3140252bab7e97195cfbd1e3cc9327191ed197e1 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Tue, 8 Jul 2025 00:06:08 +0900 Subject: [PATCH 076/339] =?UTF-8?q?[Feature]=20=EC=9D=B8=EC=A6=9D=EC=9D=84?= =?UTF-8?q?=20=EC=8B=9C=EB=8F=84=ED=95=98=EA=B3=A0,=20=EC=84=B1=EA=B3=B5?= =?UTF-8?q?=EA=B3=BC=20=EC=8B=A4=ED=8C=A8=EC=97=90=20=EB=94=B0=EB=9D=BC=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EA=B0=92=EC=9D=84=20=EB=8B=A4=EB=A5=B4?= =?UTF-8?q?=EA=B2=8C=20=EC=A3=BC=EB=8A=94=20JwtLoginFilter=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/auth/filter/JwtLoginFilter.java | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java new file mode 100644 index 0000000..e319ec7 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java @@ -0,0 +1,106 @@ +package PerfumeOnMe.spring.config.security.auth.filter; + +import static PerfumeOnMe.spring.apiPayload.ApiResponse.*; + +import java.io.IOException; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.config.security.auth.dto.AuthRequestDTO; +import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +/* +/auth/login 경로로 요청이 들어오면, +로그인 검증과 토큰 발급을 진행하고 +Authentication을 SecurityContextHolder에 설정하는 클래스 + */ +@Component +@RequiredArgsConstructor +public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter { + + private final JwtTokenProvider jwtTokenProvider; + + private final ObjectMapper mapper = new ObjectMapper(); + + // AuthenticationManager 주입 및 처리할 URL 설정 + @Autowired + public void setAuthenticationManager(AuthenticationManager authenticationManager) { + super.setAuthenticationManager(authenticationManager); + setFilterProcessesUrl("/auth/login"); + } + + // 인증 시도 + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws + AuthenticationException { + try { + AuthRequestDTO.Login requestDTO = mapper.readValue(request.getInputStream(), AuthRequestDTO.Login.class); + UsernamePasswordAuthenticationToken loginRequest = UsernamePasswordAuthenticationToken + .unauthenticated(requestDTO.getLoginId(), requestDTO.getPassword()); + return this.getAuthenticationManager().authenticate(loginRequest); + } catch (IOException e) { + throw new GeneralException(ErrorStatus.LOGIN_PARSING_FAIL); + } + } + + // 인증 시도에 성공하면 실행되는 메서드 + @Override + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, + FilterChain chain, Authentication authResult) throws IOException, ServletException { + + // 토큰 생성 및 DTO에 담기 + String accessToken = jwtTokenProvider.createAccessToken(authResult); + String refreshToken = jwtTokenProvider.createRefreshToken(authResult); + AuthResponseDTO.RefreshToken refreshTokenDTO = AuthResponseDTO + .RefreshToken.builder() + .refreshToken(refreshToken) + .build(); + + // 응답 헤더 작성 + response.setCharacterEncoding("UTF-8"); + response.setContentType("application/json"); + response.setStatus(HttpServletResponse.SC_OK); + response.setHeader("Authorization", "Bearer " + accessToken); + + // 응답 데이터 생성 및 작성 + ApiResponse res = ApiResponse.onSuccess(refreshTokenDTO); + response.getWriter().write(mapper.writeValueAsString(res)); + + // SecurityContextHolder에 인증 설정 + SecurityContextHolder.getContext().setAuthentication(authResult); + } + + // 인증 시도에 실패하면 실행되는 메서드 + @Override + protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, + AuthenticationException failed) throws IOException, ServletException { + + if (failed instanceof BadCredentialsException) { + setErrorResponse(response, ErrorStatus.PASSWORD_NOT_MATCH, failed); + } else if (failed instanceof UsernameNotFoundException) { + setErrorResponse(response, ErrorStatus.LOGIN_ID_NOT_FOUND, failed); + } else { // 예외 분기 처리 더 해야 함 + setErrorResponse(response, ErrorStatus.LOGIN_UNKNOWN_ERROR, failed); + } + } +} From 94e4e18c51bfd3b0f156cde62636fea02109482c Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Tue, 8 Jul 2025 00:07:47 +0900 Subject: [PATCH 077/339] =?UTF-8?q?[Feature]=20=EC=9A=94=EC=B2=AD=EA=B3=BC?= =?UTF-8?q?=20=ED=95=A8=EA=BB=98=20=EB=93=A4=EC=96=B4=EC=98=A8=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=EC=9D=84=20=EA=B2=80=EC=A6=9D=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=EC=9D=84=20=EC=84=A4=EC=A0=95=ED=95=98?= =?UTF-8?q?=EB=8A=94=20JwtAuthenticationFilter=20=EC=B6=94=EA=B0=80=20(#12?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/filter/JwtAuthenticationFilter.java | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtAuthenticationFilter.java diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtAuthenticationFilter.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..57286d4 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtAuthenticationFilter.java @@ -0,0 +1,46 @@ +package PerfumeOnMe.spring.config.security.auth.filter; + +import java.io.IOException; + +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; +import PerfumeOnMe.spring.config.security.auth.token.JwtAuthenticationToken; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +/* +요청에서 토큰을 추출해 유효성을 검증하고, +Authentication을 SecurityContextHolder에 설정하는 클래스 + */ +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + private final AuthenticationManager authenticationManager; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + String accessToken = jwtTokenProvider.resolveToken(request); + + if (StringUtils.hasText(accessToken)) { + String loginId = jwtTokenProvider.getSubject(accessToken); + JwtAuthenticationToken authRequest = new JwtAuthenticationToken(loginId, accessToken); + Authentication authResult = authenticationManager.authenticate(authRequest); + SecurityContextHolder.getContext().setAuthentication(authResult); + } + + filterChain.doFilter(request, response); + } +} From 28de08392d704d74ef6bd641f1a67704519f1bb3 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Tue, 8 Jul 2025 00:09:14 +0900 Subject: [PATCH 078/339] =?UTF-8?q?[Feature]=20JwtAuthenticationToken?= =?UTF-8?q?=EA=B3=BC=20=EA=B4=80=EB=A0=A8=EB=90=9C=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=EC=9D=84=20=EB=B6=80=EC=97=AC=ED=95=98=EB=8A=94=20JwtAuthentic?= =?UTF-8?q?ationProvider=20=EC=B6=94=EA=B0=80=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../provider/JwtAuthenticationProvider.java | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtAuthenticationProvider.java diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtAuthenticationProvider.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtAuthenticationProvider.java new file mode 100644 index 0000000..cd63543 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtAuthenticationProvider.java @@ -0,0 +1,34 @@ +package PerfumeOnMe.spring.config.security.auth.provider; + +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Component; + +import PerfumeOnMe.spring.config.security.auth.token.JwtAuthenticationToken; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationProvider implements AuthenticationProvider { + + private final JwtTokenProvider jwtTokenProvider; + private final UserDetailsService userDetailsService; + + @Override + public Authentication authenticate(Authentication authentication) throws AuthenticationException { + String token = authentication.getCredentials().toString(); + String loginId = jwtTokenProvider.getSubject(token); + + UserDetails userDetails = userDetailsService.loadUserByUsername(loginId); + + return new JwtAuthenticationToken(userDetails); + } + + @Override + public boolean supports(Class authentication) { + return JwtAuthenticationToken.class.isAssignableFrom(authentication); + } +} From e09357f1fcf7221ad0367492f7ef4c4cf3838415 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Tue, 8 Jul 2025 00:10:37 +0900 Subject: [PATCH 079/339] =?UTF-8?q?[Feature]=20CORS=20=EB=B9=88=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D,=20filter=EC=99=80=20handler,=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EC=97=AC=EB=B6=80=EB=A5=BC=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?=ED=95=98=EC=A7=80=20=EC=95=8A=EC=9D=84=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/security/SecurityConfig.java | 61 ++++++++++++++++--- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java b/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java index edb6f7c..e1e6f54 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java @@ -1,35 +1,82 @@ package PerfumeOnMe.spring.config.security; +import java.util.List; + import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import PerfumeOnMe.spring.config.security.auth.filter.JwtAuthenticationFilter; +import PerfumeOnMe.spring.config.security.auth.filter.JwtExceptionHandlerFilter; +import PerfumeOnMe.spring.config.security.auth.filter.JwtLoginFilter; +import PerfumeOnMe.spring.config.security.auth.handler.JwtAccessDeniedHandler; +import PerfumeOnMe.spring.config.security.auth.handler.JwtAuthenticationEntryPoint; +import lombok.RequiredArgsConstructor; @Configuration @EnableWebSecurity +@RequiredArgsConstructor public class SecurityConfig { + public static final String[] AUTH_WHITELIST = { + "/v3/api-docs/**", "/swagger-resources/**", "/swagger-ui.html", "/swagger-ui/**", + "/swagger/**", "/users/signup", "/auth/login", "/auth/social/kakao", "/users/reissue" + }; + private final JwtAuthenticationFilter JwtAuthenticationFilter; + private final JwtExceptionHandlerFilter JwtExceptionHandlerFilter; + private final JwtAuthenticationEntryPoint JwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler JwtAccessDeniedHandler; + private final JwtLoginFilter jwtLoginFilter; + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http + // 요청 경로별 인증 확인 설정 .authorizeHttpRequests(auth -> auth - .anyRequest().permitAll() + .requestMatchers(AUTH_WHITELIST).permitAll() + .anyRequest().authenticated() ) + // filter 레벨에서 발생하는 예외 핸들러 설정 + .exceptionHandling(exception -> exception + .authenticationEntryPoint(JwtAuthenticationEntryPoint) + .accessDeniedHandler(JwtAccessDeniedHandler)) + // filter 추가 및 수정 + .addFilterAt(jwtLoginFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(JwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(JwtExceptionHandlerFilter, JwtAuthenticationFilter.class) + // Session 관련 설정 - 소셜 로그인 과정에서 필요할까봐 IF_REQUIRED로 설정 + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) + // 자동 로그인 페이지, Basic 로그인, CSRF 비활성화 .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) - .csrf(AbstractHttpConfigurer::disable); + .csrf(AbstractHttpConfigurer::disable) + // CORS에 아래에서 등록한 빈 설정 + .cors(cors -> cors.configurationSource(corsConfigurationSource())); return http.build(); } - // 문자열 암호화를 위한 PasswordEncoder 빈 등록 + // CORS 설정 및 빈 등록 @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(List.of("*")); // 변경 예정 + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("Authorization", "Refresh-Token", "Content-Type")); + config.setAllowCredentials(false); // origin 바꾸면 true로 설정 + config.setExposedHeaders(List.of("Authorization", "Refresh-Token", "Content-Type")); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; } } From f7cc6734a92100ab7b8338a1e2f5411dacc5ab24 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Tue, 8 Jul 2025 00:19:28 +0900 Subject: [PATCH 080/339] =?UTF-8?q?[Feature]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=ED=9B=84=20=EB=B0=9C=EA=B8=89=EB=90=9C=20=EB=A6=AC=ED=94=84?= =?UTF-8?q?=EB=A0=88=EC=8B=9C=20=ED=86=A0=ED=81=B0=EC=9D=84=20Redis?= =?UTF-8?q?=EC=97=90=20=EC=A0=80=EC=9E=A5=ED=95=98=EB=8A=94=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/auth/filter/JwtLoginFilter.java | 6 +++ .../repository/RefreshTokenRepository.java | 42 +++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/auth/repository/RefreshTokenRepository.java diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java index e319ec7..e6f2120 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java @@ -23,6 +23,7 @@ import PerfumeOnMe.spring.config.security.auth.dto.AuthRequestDTO; import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; +import PerfumeOnMe.spring.config.security.auth.repository.RefreshTokenRepository; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -39,6 +40,7 @@ public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter { private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRepository refreshTokenRepository; private final ObjectMapper mapper = new ObjectMapper(); @@ -69,6 +71,7 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR FilterChain chain, Authentication authResult) throws IOException, ServletException { // 토큰 생성 및 DTO에 담기 + String loginId = authResult.getName(); String accessToken = jwtTokenProvider.createAccessToken(authResult); String refreshToken = jwtTokenProvider.createRefreshToken(authResult); AuthResponseDTO.RefreshToken refreshTokenDTO = AuthResponseDTO @@ -76,6 +79,9 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR .refreshToken(refreshToken) .build(); + // 리프레시 토큰을 Redis에 저장 + refreshTokenRepository.saveRefreshToken(loginId, refreshToken); + // 응답 헤더 작성 response.setCharacterEncoding("UTF-8"); response.setContentType("application/json"); diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/repository/RefreshTokenRepository.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..71ed524 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,42 @@ +package PerfumeOnMe.spring.config.security.auth.repository; + +import java.time.Duration; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class RefreshTokenRepository { + + private static final String REFRESH_TOKEN_PREFIX = "RT:"; + private final StringRedisTemplate stringRedisTemplate; + private final JwtTokenProvider jwtTokenProvider; + + public void saveRefreshToken(String loginId, String refreshToken) { + long expirationMillis = jwtTokenProvider.getExpiration(refreshToken); + long nowMillis = System.currentTimeMillis(); + long durationMillis = expirationMillis - nowMillis; + + String key = REFRESH_TOKEN_PREFIX + loginId; + stringRedisTemplate.opsForValue().set(key, refreshToken, Duration.ofMillis(durationMillis)); + } + + public boolean findRefreshToken(String loginId) { + String key = REFRESH_TOKEN_PREFIX + loginId; + return stringRedisTemplate.hasKey(key); + } + + public String getRefreshToken(String loginId) { + String key = REFRESH_TOKEN_PREFIX + loginId; + return stringRedisTemplate.opsForValue().get(key); + } + + public void deleteRefreshToken(String loginId) { + String key = REFRESH_TOKEN_PREFIX + loginId; + stringRedisTemplate.delete(key); + } +} From 8d27037d66dd916b8384fdfda6f0eb466479a33d Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Tue, 8 Jul 2025 00:55:24 +0900 Subject: [PATCH 081/339] =?UTF-8?q?[Bug]=20GrantedAuthority=EB=A5=BC=20?= =?UTF-8?q?=ED=8F=AC=ED=95=A8=ED=95=9C=20=EC=83=9D=EC=84=B1=EC=9E=90?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/provider/JwtAuthenticationProvider.java | 2 +- .../security/auth/token/JwtAuthenticationToken.java | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtAuthenticationProvider.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtAuthenticationProvider.java index cd63543..ecb721f 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtAuthenticationProvider.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtAuthenticationProvider.java @@ -24,7 +24,7 @@ public Authentication authenticate(Authentication authentication) throws Authent UserDetails userDetails = userDetailsService.loadUserByUsername(loginId); - return new JwtAuthenticationToken(userDetails); + return new JwtAuthenticationToken(userDetails, null, userDetails.getAuthorities()); } @Override diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/token/JwtAuthenticationToken.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/token/JwtAuthenticationToken.java index 2d0ad46..d9dacba 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/token/JwtAuthenticationToken.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/token/JwtAuthenticationToken.java @@ -1,6 +1,9 @@ package PerfumeOnMe.spring.config.security.auth.token; +import java.util.Collection; + import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; import lombok.Getter; @@ -15,14 +18,14 @@ public JwtAuthenticationToken(Object principal, Object credentials) { super(principal, credentials); this.principal = principal; this.credentials = credentials; - setAuthenticated(false); } - public JwtAuthenticationToken(UserDetails userDetails) { - super(userDetails, null); + // GrantedAuthority를 포함한 생성자를 만들어야 신뢰할 수 있는, 인증된 토큰이 됨 + public JwtAuthenticationToken(UserDetails userDetails, Object o, + Collection authorities) { + super(userDetails, null, authorities); this.principal = userDetails; this.credentials = null; - setAuthenticated(true); } @Override From 513f2edcbb7970a0ad86bd3b9c396d487078c6b3 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Tue, 8 Jul 2025 00:58:35 +0900 Subject: [PATCH 082/339] =?UTF-8?q?[Feature]=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=9E=AC=EB=B0=9C=EA=B8=89=20API=20=EA=B5=AC=ED=98=84=20(#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/service/user/UserService.java | 4 ++ .../spring/service/user/UserServiceImpl.java | 38 +++++++++++++++++++ .../spring/web/controller/UserController.java | 23 +++++++++++ 3 files changed, 65 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserService.java b/src/main/java/PerfumeOnMe/spring/service/user/UserService.java index fe6d507..0b66fee 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserService.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserService.java @@ -1,9 +1,13 @@ package PerfumeOnMe.spring.service.user; +import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; import PerfumeOnMe.spring.web.dto.user.UserRequestDTO; import PerfumeOnMe.spring.web.dto.user.UserResponseDTO; +import jakarta.servlet.http.HttpServletResponse; public interface UserService { UserResponseDTO.SignupResult signup(UserRequestDTO.Signup request); + + AuthResponseDTO.RefreshToken reissue(String refreshToken, HttpServletResponse response); } diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java index 28b2ffe..72f9bb2 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java @@ -2,17 +2,24 @@ import java.util.Optional; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; +import PerfumeOnMe.spring.config.security.auth.repository.RefreshTokenRepository; +import PerfumeOnMe.spring.config.security.auth.token.JwtAuthenticationToken; import PerfumeOnMe.spring.converter.UserConverter; import PerfumeOnMe.spring.domain.User; import PerfumeOnMe.spring.repository.user.UserRepository; import PerfumeOnMe.spring.web.dto.user.UserRequestDTO; import PerfumeOnMe.spring.web.dto.user.UserResponseDTO; +import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @Service @@ -22,6 +29,9 @@ public class UserServiceImpl implements UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + private final UserDetailsService userDetailsService; // 사용자 회원가입 @Override @@ -48,4 +58,32 @@ public UserResponseDTO.SignupResult signup(UserRequestDTO.Signup request) { // 사용자 회원가입 결과를 ResponseDTO로 응답 return UserConverter.toSignupResult(newUser); } + + @Override + public AuthResponseDTO.RefreshToken reissue(String reqRefreshToken, HttpServletResponse response) { + + // 리프레시 토큰에서 Subject 추출 + String loginId = jwtTokenProvider.getSubject(reqRefreshToken); + + // 토큰 생성 및 DTO에 담기 + UserDetails userDetails = userDetailsService.loadUserByUsername(loginId); + JwtAuthenticationToken request = new JwtAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + String accessToken = jwtTokenProvider.createAccessToken(request); + String refreshToken = jwtTokenProvider.createRefreshToken(request); + AuthResponseDTO.RefreshToken refreshTokenDTO = AuthResponseDTO + .RefreshToken.builder() + .refreshToken(refreshToken) + .build(); + + // 새로 발급한 리프레시 토큰을 Redis에 저장 - 덮어씌우기 + refreshTokenRepository.saveRefreshToken(loginId, refreshToken); + + // 응답 헤더 작성 + response.setCharacterEncoding("UTF-8"); + response.setContentType("application/json"); + response.setStatus(HttpServletResponse.SC_OK); + response.setHeader("Authorization", "Bearer " + accessToken); + + return refreshTokenDTO; + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java index 8f9e6b7..c1e681f 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java @@ -4,18 +4,22 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import PerfumeOnMe.spring.apiPayload.ApiResponse; import PerfumeOnMe.spring.apiPayload.code.status.SuccessStatus; +import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; import PerfumeOnMe.spring.service.user.UserService; import PerfumeOnMe.spring.web.dto.user.UserRequestDTO; import PerfumeOnMe.spring.web.dto.user.UserResponseDTO; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -44,4 +48,23 @@ public ResponseEntity> signup( UserResponseDTO.SignupResult result = userService.signup(request); return new ResponseEntity<>(ApiResponse.of(SuccessStatus._CREATED, result), HttpStatus.CREATED); } + + @PostMapping("/reissue") + @Operation( + summary = "토큰 재발급 API", + description = "헤더에 입력한 Refresh-Token으로 새로운 액세스 토큰과 리프레시 토큰을 발급하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "", description = "") + }, + parameters = { + @Parameter(name = "refreshToken", description = "Refresh-Token 헤더에 리프레시 토큰을 입력해 주세요.") + } + ) + public ResponseEntity> reissue( + @RequestHeader(name = "Refresh-Token") String refreshToken, + HttpServletResponse response) { + AuthResponseDTO.RefreshToken result = userService.reissue(refreshToken, response); + return ResponseEntity.ok().body(ApiResponse.onSuccess(result)); + } } From 4a14a058bcc63394c02573f7f5bf8ce9bb62b570 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Tue, 8 Jul 2025 14:03:32 +0900 Subject: [PATCH 083/339] =?UTF-8?q?[STYLE]=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=20=EC=9D=B4=EB=A6=84=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/apiPayload/ApiResponse.java | 3 ++- .../config/security/AuthenticationManagerConfig.java | 3 +++ .../spring/config/security/PasswordEncoderConfig.java | 3 +++ .../spring/config/security/SecurityConfig.java | 1 + .../config/security/auth/dto/JwtProperties.java | 3 +++ .../config/security/auth/filter/JwtLoginFilter.java | 10 +++++----- .../RefreshTokenManager.java} | 11 +++++++---- .../provider/CustomLoginAuthenticationProvider.java | 4 ++++ .../auth/provider/JwtAuthenticationProvider.java | 5 +++++ .../security/auth/provider/JwtTokenProvider.java | 2 +- .../security/auth/token/JwtAuthenticationToken.java | 3 +++ .../spring/service/user/UserServiceImpl.java | 7 ++++--- 12 files changed, 41 insertions(+), 14 deletions(-) rename src/main/java/PerfumeOnMe/spring/config/security/auth/{repository/RefreshTokenRepository.java => manager/RefreshTokenManager.java} (83%) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/ApiResponse.java b/src/main/java/PerfumeOnMe/spring/apiPayload/ApiResponse.java index 93df12e..52f7af2 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/ApiResponse.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/ApiResponse.java @@ -43,6 +43,7 @@ public static ApiResponse onFailure(String code, String message, T data) return new ApiResponse<>(false, code, message, data); } + // Security Filter 레벨에서 사용하는 ErrorResponse 생성 메서드 public static void setErrorResponse(HttpServletResponse response, ErrorStatus code, Throwable e) throws IOException { @@ -53,7 +54,7 @@ public static void setErrorResponse(HttpServletResponse response, // 응답 데이터 생성 및 작성 ApiResponse res = ApiResponse - .onFailure(code.getCode(), code.getMessage(), null); // ???로 찍히는 에러 발생 중 + .onFailure(code.getCode(), code.getMessage(), null); response.getWriter().write(mapper.writeValueAsString(res)); } } diff --git a/src/main/java/PerfumeOnMe/spring/config/security/AuthenticationManagerConfig.java b/src/main/java/PerfumeOnMe/spring/config/security/AuthenticationManagerConfig.java index 5147687..4c54a6d 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/AuthenticationManagerConfig.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/AuthenticationManagerConfig.java @@ -12,6 +12,9 @@ import PerfumeOnMe.spring.config.security.auth.provider.JwtAuthenticationProvider; import lombok.RequiredArgsConstructor; +/* +SecurityConfig와의 빈 순환 참조를 방지하기 위해 클래스 분리 + */ @Configuration @RequiredArgsConstructor public class AuthenticationManagerConfig { diff --git a/src/main/java/PerfumeOnMe/spring/config/security/PasswordEncoderConfig.java b/src/main/java/PerfumeOnMe/spring/config/security/PasswordEncoderConfig.java index 616a8dc..14d51de 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/PasswordEncoderConfig.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/PasswordEncoderConfig.java @@ -5,6 +5,9 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; +/* +SecurityConfig와의 빈 순환 참조를 방지하기 위해 클래스 분리 + */ @Configuration public class PasswordEncoderConfig { diff --git a/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java b/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java index e1e6f54..fab0c5a 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java @@ -26,6 +26,7 @@ @RequiredArgsConstructor public class SecurityConfig { + // 인증 여부를 확인하지 않을 경로 지정 public static final String[] AUTH_WHITELIST = { "/v3/api-docs/**", "/swagger-resources/**", "/swagger-ui.html", "/swagger-ui/**", "/swagger/**", "/users/signup", "/auth/login", "/auth/social/kakao", "/users/reissue" diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/JwtProperties.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/JwtProperties.java index c831c33..6d39a22 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/JwtProperties.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/JwtProperties.java @@ -6,6 +6,9 @@ import lombok.Getter; import lombok.Setter; +/* +application.yml에 설정해둔 값을 담아오기 위한 DTO + */ @Component @Getter @Setter diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java index e6f2120..765bc4e 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java @@ -22,8 +22,8 @@ import PerfumeOnMe.spring.apiPayload.exception.GeneralException; import PerfumeOnMe.spring.config.security.auth.dto.AuthRequestDTO; import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.config.security.auth.manager.RefreshTokenManager; import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; -import PerfumeOnMe.spring.config.security.auth.repository.RefreshTokenRepository; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -32,15 +32,15 @@ /* /auth/login 경로로 요청이 들어오면, -로그인 검증과 토큰 발급을 진행하고 -Authentication을 SecurityContextHolder에 설정하는 클래스 +로그인 검증과 토큰 발급을 진행하고 Authentication을 SecurityContextHolder에 설정하는 클래스 +성공 및 실패에 따른 핸들러도 구현 */ @Component @RequiredArgsConstructor public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter { private final JwtTokenProvider jwtTokenProvider; - private final RefreshTokenRepository refreshTokenRepository; + private final RefreshTokenManager refreshTokenManager; private final ObjectMapper mapper = new ObjectMapper(); @@ -80,7 +80,7 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR .build(); // 리프레시 토큰을 Redis에 저장 - refreshTokenRepository.saveRefreshToken(loginId, refreshToken); + refreshTokenManager.saveRefreshToken(loginId, refreshToken); // 응답 헤더 작성 response.setCharacterEncoding("UTF-8"); diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/repository/RefreshTokenRepository.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/manager/RefreshTokenManager.java similarity index 83% rename from src/main/java/PerfumeOnMe/spring/config/security/auth/repository/RefreshTokenRepository.java rename to src/main/java/PerfumeOnMe/spring/config/security/auth/manager/RefreshTokenManager.java index 71ed524..f46c234 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/repository/RefreshTokenRepository.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/manager/RefreshTokenManager.java @@ -1,16 +1,19 @@ -package PerfumeOnMe.spring.config.security.auth.repository; +package PerfumeOnMe.spring.config.security.auth.manager; import java.time.Duration; import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Repository; +import org.springframework.stereotype.Component; import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; import lombok.RequiredArgsConstructor; -@Repository +/* +리프레시 토큰을 Redis에 저장, 수정, 삭제를 담당하는 클래스 + */ +@Component @RequiredArgsConstructor -public class RefreshTokenRepository { +public class RefreshTokenManager { private static final String REFRESH_TOKEN_PREFIX = "RT:"; private final StringRedisTemplate stringRedisTemplate; diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/CustomLoginAuthenticationProvider.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/CustomLoginAuthenticationProvider.java index b9a68d8..a26e56d 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/CustomLoginAuthenticationProvider.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/CustomLoginAuthenticationProvider.java @@ -12,6 +12,10 @@ import lombok.RequiredArgsConstructor; +/* +로그인 시 사용되는 AuthenticationProvider +요청에서 아이디와 비밀번호를 추출해 검증하고, 성공하면 Authentication 반환 + */ @Component @RequiredArgsConstructor public class CustomLoginAuthenticationProvider implements AuthenticationProvider { diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtAuthenticationProvider.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtAuthenticationProvider.java index ecb721f..069381e 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtAuthenticationProvider.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtAuthenticationProvider.java @@ -10,6 +10,11 @@ import PerfumeOnMe.spring.config.security.auth.token.JwtAuthenticationToken; import lombok.RequiredArgsConstructor; +/* +JWT 인증 시 사용되는 AuthenticationProvider +Subject와 토큰을 검증하고, 성공하면 Authentication 반환 +JwtAuthenticationToken만 지원할 수 있음 + */ @Component @RequiredArgsConstructor public class JwtAuthenticationProvider implements AuthenticationProvider { diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtTokenProvider.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtTokenProvider.java index 018a0f0..55c5f94 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtTokenProvider.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtTokenProvider.java @@ -95,7 +95,7 @@ public boolean validateToken(String token) { } } - // 헤더에서 토큰 추출 + // 요청에서 토큰 추출 public String resolveToken(HttpServletRequest request) { String bearerToken = request.getHeader("Authorization"); if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/token/JwtAuthenticationToken.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/token/JwtAuthenticationToken.java index d9dacba..bbbc84a 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/token/JwtAuthenticationToken.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/token/JwtAuthenticationToken.java @@ -8,6 +8,9 @@ import lombok.Getter; +/* +JWT 인증용 AuthenticationToken + */ @Getter public class JwtAuthenticationToken extends UsernamePasswordAuthenticationToken { diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java index 72f9bb2..d78cbb0 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java @@ -11,8 +11,8 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.config.security.auth.manager.RefreshTokenManager; import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; -import PerfumeOnMe.spring.config.security.auth.repository.RefreshTokenRepository; import PerfumeOnMe.spring.config.security.auth.token.JwtAuthenticationToken; import PerfumeOnMe.spring.converter.UserConverter; import PerfumeOnMe.spring.domain.User; @@ -30,7 +30,7 @@ public class UserServiceImpl implements UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; - private final RefreshTokenRepository refreshTokenRepository; + private final RefreshTokenManager refreshTokenManager; private final UserDetailsService userDetailsService; // 사용자 회원가입 @@ -59,6 +59,7 @@ public UserResponseDTO.SignupResult signup(UserRequestDTO.Signup request) { return UserConverter.toSignupResult(newUser); } + // 리프레시 토큰으로 액세스 토큰과 리프레시 토큰 재발급 @Override public AuthResponseDTO.RefreshToken reissue(String reqRefreshToken, HttpServletResponse response) { @@ -76,7 +77,7 @@ public AuthResponseDTO.RefreshToken reissue(String reqRefreshToken, HttpServletR .build(); // 새로 발급한 리프레시 토큰을 Redis에 저장 - 덮어씌우기 - refreshTokenRepository.saveRefreshToken(loginId, refreshToken); + refreshTokenManager.saveRefreshToken(loginId, refreshToken); // 응답 헤더 작성 response.setCharacterEncoding("UTF-8"); From 3e3a01a964d96c1b4ec77e1c962cd800b8b34412 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Tue, 8 Jul 2025 14:38:27 +0900 Subject: [PATCH 084/339] =?UTF-8?q?[Feature]=20=ED=96=A5=EC=88=98=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=9D=91=EB=8B=B5=20DTO=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/dto/fragrance/FragranceResponseDTO.java | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java index 4dbb8fd..7940f91 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java @@ -9,7 +9,7 @@ public class FragranceResponseDTO { - // 향수 상세 페이지 응답 결과 + // 향수 상세 DTO @Getter @Builder @AllArgsConstructor @@ -67,6 +67,19 @@ public static class FragranceTypeDto { private String diffusionPower; } } + + // 향수 검색 DTO + @Getter + @Builder + @AllArgsConstructor + public static class FragranceSearchResult { + private Long id; + private String brand; + private String name; + private Integer minPrice; + private String imageUrl; + } + } From 9a46c55dc0eeff182ba2b4b5dcb6ce368be24099 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Tue, 8 Jul 2025 14:42:28 +0900 Subject: [PATCH 085/339] =?UTF-8?q?[Feature]=20DTO=20=EB=B0=98=ED=99=98=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B0=98=ED=99=98=ED=95=9C=20DTO=20=EB=A5=BC=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0?= =?UTF-8?q?=EB=A1=9C=20=EA=B0=80=EC=A7=80=EB=8A=94=20=EB=A6=AC=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20=EB=B0=98=ED=99=98=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/converter/FragranceConverter.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java index 593b56d..2abaf8d 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java @@ -1,5 +1,6 @@ package PerfumeOnMe.spring.converter; +import java.util.Comparator; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; @@ -18,6 +19,9 @@ public class FragranceConverter { + /** + * 향수 상세 페이지 조회 API + */ public static FragranceResponseDTO.FragranceDetailResult toDetailDto(Fragrance fragrance) { return FragranceResponseDTO.FragranceDetailResult.builder() .id(fragrance.getId()) @@ -104,4 +108,31 @@ private static FragranceResponseDTO.FragranceDetailResult.NoteDto.NoteSection to .ingredients(ingredients) .build(); } + + /** + * 향수 검색 API + */ + public static FragranceResponseDTO.FragranceSearchResult toSearchResultDto(Fragrance fragrance) { + Integer minPrice = fragrance.getFragrancePriceList().stream() + .map(fp -> fp.getPrice().getPrice()) + .min(Comparator.naturalOrder()) // 각 향수의 최저가 + .orElse(null); + + return FragranceResponseDTO.FragranceSearchResult.builder() + .id(fragrance.getId()) + .brand(fragrance.getBrand().getShowBrand()) + .name(fragrance.getName()) + .minPrice(minPrice) + .imageUrl(fragrance.getImageURL()) + .build(); + } + + // toSearchResultDto 로 얻은 향수들의 리스트 + public static List toSearchResultDtoList( + List fragranceList) { + return fragranceList.stream() + .map(FragranceConverter::toSearchResultDto) + .collect(Collectors.toList()); + } + } From 920fe541a36fab719cf6ab1b81791d9c9abd6e58 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Tue, 8 Jul 2025 14:46:44 +0900 Subject: [PATCH 086/339] =?UTF-8?q?[Feature]=20=ED=96=A5=EC=88=98=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20API=20=EA=B5=AC=ED=98=84=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fragrance/FragranceRepositoryCustom.java | 5 +++ .../fragrance/FragranceRepositoryImpl.java | 34 +++++++++++++++++++ .../service/fragrance/FragranceService.java | 6 ++++ .../fragrance/FragranceServiceImpl.java | 23 +++++++++++++ .../web/controller/FragranceController.java | 33 +++++++++++++++++- 5 files changed, 100 insertions(+), 1 deletion(-) diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java index 384e07d..f712fe8 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java @@ -2,8 +2,13 @@ import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + import PerfumeOnMe.spring.domain.Fragrance; public interface FragranceRepositoryCustom { Optional findByIdWithAllDetails(Long id); + + Page findByKeyword(String keyword, Pageable pageable); } diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java index cbfaedb..2590fe6 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java @@ -1,9 +1,15 @@ package PerfumeOnMe.spring.repository.fragrance; +import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.support.PageableExecutionUtils; import org.springframework.stereotype.Repository; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.StringPath; import com.querydsl.jpa.impl.JPAQueryFactory; import PerfumeOnMe.spring.domain.Fragrance; @@ -32,4 +38,32 @@ public Optional findByIdWithAllDetails(Long id) { return Optional.ofNullable(result); } + + @Override + public Page findByKeyword(String keyword, Pageable pageable) { + QFragrance fragrance = QFragrance.fragrance; + + // 실제 결과 데이터 조회 + List content = queryFactory + .selectFrom(fragrance) + .where(containsKeyword(fragrance.name, keyword)) + .offset(pageable.getOffset()) // 몇 번째부터 가져올지 (page * size) + .limit(pageable.getPageSize()) // 몇 개 가져올지 + .fetch(); + + // 카운트 쿼리 -> 총 조회된 향수가 몇 개인지 + Long count = queryFactory + .select(fragrance.count()) + .from(fragrance) + .where(containsKeyword(fragrance.name, keyword)) + .fetchOne(); + + // Page 객체 생성 + return PageableExecutionUtils.getPage(content, pageable, () -> count != null ? count : 0); + } + + private BooleanExpression containsKeyword(StringPath field, String keyword) { + return keyword == null ? null : field.containsIgnoreCase(keyword); + } + } diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java index 5dc3be1..c5e0bc0 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java @@ -1,8 +1,14 @@ package PerfumeOnMe.spring.service.fragrance; +import java.util.Map; + import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; public interface FragranceService { + // 향수 상세 API FragranceResponseDTO.FragranceDetailResult getFragranceDetail(Long fragranceId); + + // 향수 검색 API + Map searchFragrances(String keyword, int page, int size); } diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java index af91b0d..b4512fd 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java @@ -1,5 +1,11 @@ package PerfumeOnMe.spring.service.fragrance; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -18,6 +24,7 @@ public class FragranceServiceImpl implements FragranceService { private final FragranceRepository fragranceRepository; + // 향수 상세 API @Override public FragranceResponseDTO.FragranceDetailResult getFragranceDetail(Long fragranceId) { Fragrance fragrance = fragranceRepository.findByIdWithAllDetails(fragranceId) @@ -25,4 +32,20 @@ public FragranceResponseDTO.FragranceDetailResult getFragranceDetail(Long fragra return FragranceConverter.toDetailDto(fragrance); } + // 향수 검색 API + @Override + public Map searchFragrances(String keyword, int page, int size) { + PageRequest pageable = PageRequest.of(page, size); + Page fragrancePage = fragranceRepository.findByKeyword(keyword, pageable); + + List dtoList = FragranceConverter.toSearchResultDtoList( + fragrancePage.getContent()); + + Map result = new HashMap<>(); + result.put("fragranceList", dtoList); + result.put("hasNext", fragrancePage.hasNext()); + + return result; + } + } diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java index 2d05c92..3ee3cfb 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java @@ -1,12 +1,17 @@ package PerfumeOnMe.spring.web.controller; +import java.util.Map; + import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; import PerfumeOnMe.spring.service.fragrance.FragranceService; import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; import io.swagger.v3.oas.annotations.Operation; @@ -18,11 +23,12 @@ @RestController @RequiredArgsConstructor @RequestMapping("/fragrances") -@Tag(name = "Fragrance", description = "향수 조회 API") +@Tag(name = "Fragrance", description = "향수 CRUD API") public class FragranceController { private final FragranceService fragranceService; + // 향수 상세 API @GetMapping("/{fragranceId}") @Operation( summary = "향수 상세 조회", @@ -37,4 +43,29 @@ public ResponseEntity> g FragranceResponseDTO.FragranceDetailResult result = fragranceService.getFragranceDetail(fragranceId); return ResponseEntity.ok(ApiResponse.onSuccess(result)); } + + // 향수 검색 API + @GetMapping("/search") + @Operation( + summary = "향수 키워드 검색 (무한 스크롤)", + description = "keyword 로 향수 이름을 검색하고, 페이징 처리된 결과를 반환합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "요청에 성공하였습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = FragranceResponseDTO.FragranceSearchResult.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FRAGRANCE4002", description = "검색어를 2글자 이상 입력해주세요.") + } + ) + public ResponseEntity>> searchFragrances( + @RequestParam String keyword, // 검색어 + @RequestParam int page, // 페이지 번호 + @RequestParam(defaultValue = "12") int size // 한 페이지에 불러올 향수 수 (default = 12) + ) { + // 검색어가 공백이거나 2글자 미만이면 KEYWORD_TOO_SHORT 발생 + if (keyword == null || keyword.trim().length() < 2) { + throw new GeneralException(ErrorStatus.KEYWORD_TOO_SHORT); + } + + // result 안에 fragranceList 와 hasNext 를 키로 갖는 구조 + Map result = fragranceService.searchFragrances(keyword, page, size); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } } From f46afbae93a83636d19e7bf4bea49258f2a99a2b Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Tue, 8 Jul 2025 14:48:14 +0900 Subject: [PATCH 087/339] =?UTF-8?q?[Feature]=20ErrorStatus=20=EC=97=90=20?= =?UTF-8?q?=ED=96=A5=EC=88=98=20=EA=B2=80=EC=83=89=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?Enum=20=EC=B6=94=EA=B0=80=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index 268dbe7..b2fe6a3 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -29,6 +29,9 @@ public enum ErrorStatus implements BaseErrorCode { // 향수 상세 페이지 에러 FRAGRANCE_NOT_FOUND(HttpStatus.BAD_REQUEST, "FRAGRANCE4001", "해당 ID에 해당하는 향수를 찾을 수 없습니다."), + // 향수 검색 에러 + KEYWORD_TOO_SHORT(HttpStatus.BAD_REQUEST, "FRAGRANCE4002", "검색어가 두 글자 이상이어야 합니다."), + // 예시,,, ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); From f39c299529bbb79f6b91baa4e497ebc203e74e4a Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Tue, 8 Jul 2025 14:57:49 +0900 Subject: [PATCH 088/339] =?UTF-8?q?[Fix]=20default=5Fbatch=5Ffetch=5Fsize?= =?UTF-8?q?=201000=EC=97=90=EC=84=9C=20100=EC=9C=BC=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index bedcaec..2b1d7f0 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -18,5 +18,5 @@ spring: dialect: org.hibernate.dialect.MySQL8Dialect format_sql: true use_sql_comments: true - default_batch_fetch_size: 1000 + default_batch_fetch_size: 100 show-sql: true \ No newline at end of file From 8fa68a8ced4376a7c30cf2f3fee1724f9d80bec0 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Tue, 8 Jul 2025 15:24:42 +0900 Subject: [PATCH 089/339] =?UTF-8?q?[Fix]=20findByKeyword=20->=20findBySear?= =?UTF-8?q?chKeyword=20=EB=A1=9C=20=EB=A9=94=EC=84=9C=EB=93=9C=EB=AA=85=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/repository/fragrance/FragranceRepositoryCustom.java | 2 +- .../spring/repository/fragrance/FragranceRepositoryImpl.java | 2 +- .../spring/service/fragrance/FragranceServiceImpl.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java index f712fe8..430e33b 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java @@ -10,5 +10,5 @@ public interface FragranceRepositoryCustom { Optional findByIdWithAllDetails(Long id); - Page findByKeyword(String keyword, Pageable pageable); + Page findBySearchKeyword(String keyword, Pageable pageable); } diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java index 2590fe6..a524a32 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java @@ -40,7 +40,7 @@ public Optional findByIdWithAllDetails(Long id) { } @Override - public Page findByKeyword(String keyword, Pageable pageable) { + public Page findBySearchKeyword(String keyword, Pageable pageable) { QFragrance fragrance = QFragrance.fragrance; // 실제 결과 데이터 조회 diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java index b4512fd..fa646ad 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java @@ -36,7 +36,7 @@ public FragranceResponseDTO.FragranceDetailResult getFragranceDetail(Long fragra @Override public Map searchFragrances(String keyword, int page, int size) { PageRequest pageable = PageRequest.of(page, size); - Page fragrancePage = fragranceRepository.findByKeyword(keyword, pageable); + Page fragrancePage = fragranceRepository.findBySearchKeyword(keyword, pageable); List dtoList = FragranceConverter.toSearchResultDtoList( fragrancePage.getContent()); From c840586bf8d2d5fc4bcd45b4d058277f5c9bf1aa Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Tue, 8 Jul 2025 16:39:18 +0900 Subject: [PATCH 090/339] =?UTF-8?q?[Feature]=20Controller=20=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B0=9B=EC=95=84=EC=98=A4=EB=8A=94=20keyword=20?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=9C=20=EB=8B=A8=EC=88=9C=20=EC=9C=A0?= =?UTF-8?q?=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=A6=9D=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=B4=20=EC=BB=A4=EC=8A=A4=ED=85=80=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EB=B0=8F=20Validator=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../validation/annotation/ValidKeyword.java | 23 +++++++++++++++++++ .../validator/ValidKeywordValidator.java | 14 +++++++++++ 2 files changed, 37 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/validation/annotation/ValidKeyword.java create mode 100644 src/main/java/PerfumeOnMe/spring/validation/validator/ValidKeywordValidator.java diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidKeyword.java b/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidKeyword.java new file mode 100644 index 0000000..8aa4a45 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidKeyword.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.validation.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import PerfumeOnMe.spring.validation.validator.ValidKeywordValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Documented +@Constraint(validatedBy = ValidKeywordValidator.class) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidKeyword { + String message() default "검색어를 2글자 이상 입력해주세요."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/PerfumeOnMe/spring/validation/validator/ValidKeywordValidator.java b/src/main/java/PerfumeOnMe/spring/validation/validator/ValidKeywordValidator.java new file mode 100644 index 0000000..5074737 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/validation/validator/ValidKeywordValidator.java @@ -0,0 +1,14 @@ +package PerfumeOnMe.spring.validation.validator; + +import PerfumeOnMe.spring.validation.annotation.ValidKeyword; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class ValidKeywordValidator implements ConstraintValidator { + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + // null 또는 공백이거나 2자 미만일 경우 false + return value != null && value.trim().length() >= 2; + } +} \ No newline at end of file From 12655ab28179737abf78e62126670b825282744e Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Tue, 8 Jul 2025 16:40:11 +0900 Subject: [PATCH 091/339] =?UTF-8?q?[Feature]=20=ED=96=A5=EC=88=98=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=9A=94=EC=B2=AD=20DTO=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/fragrance/FragranceRequestDTO.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceRequestDTO.java diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceRequestDTO.java new file mode 100644 index 0000000..c95fc9f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceRequestDTO.java @@ -0,0 +1,19 @@ +package PerfumeOnMe.spring.web.dto.fragrance; + +import PerfumeOnMe.spring.validation.annotation.ValidKeyword; +import lombok.Getter; +import lombok.Setter; + +public class FragranceRequestDTO { + @Getter + @Setter + public static class FragranceSearchRequest { + + @ValidKeyword + private String keyword; + + private int page; + + private Integer size = 12; // default + } +} From c3c46ddf0907c72269571a4ca0143516fda2acaa Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Tue, 8 Jul 2025 16:40:28 +0900 Subject: [PATCH 092/339] =?UTF-8?q?[Fix]=20@NoArgsConstructor=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/web/dto/fragrance/FragranceResponseDTO.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java index 7940f91..e50d45c 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java @@ -72,6 +72,7 @@ public static class FragranceTypeDto { @Getter @Builder @AllArgsConstructor + @NoArgsConstructor public static class FragranceSearchResult { private Long id; private String brand; From 1d366dc80a01325483dfa2ed7597ab4d890a8f52 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Tue, 8 Jul 2025 16:42:27 +0900 Subject: [PATCH 093/339] =?UTF-8?q?[Fix]=20Swagger=20=EC=97=90=20Parameter?= =?UTF-8?q?s=20=EC=84=A4=EB=AA=85=20=EC=B6=94=EA=B0=80,=20@RequestParam=20?= =?UTF-8?q?=EC=97=90=EC=84=9C=20@ModelAttribute=20=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=ED=95=98=EC=97=AC=20=EA=B0=9D=EC=B2=B4=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20?= =?UTF-8?q?=EB=A7=A4=ED=95=91=20=EB=B0=8F=20@Valid=20=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=98=EC=97=AC=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=20(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/controller/FragranceController.java | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java index 3ee3cfb..abc08a8 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java @@ -4,20 +4,22 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import PerfumeOnMe.spring.apiPayload.ApiResponse; -import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; -import PerfumeOnMe.spring.apiPayload.exception.GeneralException; import PerfumeOnMe.spring.service.fragrance.FragranceService; +import PerfumeOnMe.spring.web.dto.fragrance.FragranceRequestDTO; import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RestController @@ -38,6 +40,9 @@ public class FragranceController { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FRAGRANCE4001", description = "해당 ID에 해당하는 향수를 찾을 수 없습니다.") } ) + @Parameters({ + @Parameter(name = "fragranceId", description = "향수 ID"), + }) public ResponseEntity> getFragranceDetail( @PathVariable("fragranceId") Long fragranceId) { FragranceResponseDTO.FragranceDetailResult result = fragranceService.getFragranceDetail(fragranceId); @@ -54,18 +59,17 @@ public ResponseEntity> g @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FRAGRANCE4002", description = "검색어를 2글자 이상 입력해주세요.") } ) + @Parameters({ + @Parameter(name = "keyword", description = "검색어"), + @Parameter(name = "page", description = "페이지 번호"), + @Parameter(name = "size", description = "한 페이지에 불러올 향수 개수") + }) public ResponseEntity>> searchFragrances( - @RequestParam String keyword, // 검색어 - @RequestParam int page, // 페이지 번호 - @RequestParam(defaultValue = "12") int size // 한 페이지에 불러올 향수 수 (default = 12) + @Valid @ModelAttribute FragranceRequestDTO.FragranceSearchRequest request ) { - // 검색어가 공백이거나 2글자 미만이면 KEYWORD_TOO_SHORT 발생 - if (keyword == null || keyword.trim().length() < 2) { - throw new GeneralException(ErrorStatus.KEYWORD_TOO_SHORT); - } - // result 안에 fragranceList 와 hasNext 를 키로 갖는 구조 - Map result = fragranceService.searchFragrances(keyword, page, size); + Map result = fragranceService.searchFragrances(request.getKeyword(), request.getPage(), + request.getSize()); return ResponseEntity.ok(ApiResponse.onSuccess(result)); } } From 084252bc2a713c3aca8b3b1e32edead35ff29165 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Tue, 8 Jul 2025 16:52:36 +0900 Subject: [PATCH 094/339] =?UTF-8?q?[Fix]=20=EA=B2=80=EC=83=89=EC=96=B4?= =?UTF-8?q?=EC=97=90=20=EB=8C=80=ED=95=9C=20=EC=9C=A0=ED=9A=A8=EC=84=B1=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=EC=9D=84=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=EC=9D=84=20?= =?UTF-8?q?=ED=86=B5=ED=95=B4=20=ED=95=98=EB=AF=80=EB=A1=9C=20ErrorStatus?= =?UTF-8?q?=20=EC=97=90=EC=84=9C=20FRAGRANCE=5FNOT=5FFOUND=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index b2fe6a3..268dbe7 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -29,9 +29,6 @@ public enum ErrorStatus implements BaseErrorCode { // 향수 상세 페이지 에러 FRAGRANCE_NOT_FOUND(HttpStatus.BAD_REQUEST, "FRAGRANCE4001", "해당 ID에 해당하는 향수를 찾을 수 없습니다."), - // 향수 검색 에러 - KEYWORD_TOO_SHORT(HttpStatus.BAD_REQUEST, "FRAGRANCE4002", "검색어가 두 글자 이상이어야 합니다."), - // 예시,,, ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); From 3435ee44b0ae0a70880d6e5028db34e8a0186f54 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Tue, 8 Jul 2025 17:15:11 +0900 Subject: [PATCH 095/339] =?UTF-8?q?[Feature]=20page,=20size=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=BB=A4=EC=8A=A4=ED=85=80=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EB=B0=8F=20validator=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../validation/annotation/ValidPage.java | 23 +++++++++++++++++++ .../validation/annotation/ValidSize.java | 23 +++++++++++++++++++ .../validation/validator/PageValidator.java | 13 +++++++++++ .../validation/validator/SizeValidator.java | 13 +++++++++++ 4 files changed, 72 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/validation/annotation/ValidPage.java create mode 100644 src/main/java/PerfumeOnMe/spring/validation/annotation/ValidSize.java create mode 100644 src/main/java/PerfumeOnMe/spring/validation/validator/PageValidator.java create mode 100644 src/main/java/PerfumeOnMe/spring/validation/validator/SizeValidator.java diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidPage.java b/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidPage.java new file mode 100644 index 0000000..9ea309c --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidPage.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.validation.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import PerfumeOnMe.spring.validation.validator.PageValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Documented +@Constraint(validatedBy = PageValidator.class) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidPage { + String message() default "page 는 0 이상이어야 합니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidSize.java b/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidSize.java new file mode 100644 index 0000000..727a07f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidSize.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.validation.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import PerfumeOnMe.spring.validation.validator.SizeValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Documented +@Constraint(validatedBy = SizeValidator.class) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidSize { + String message() default "size 는 1 이상이어야 합니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/PerfumeOnMe/spring/validation/validator/PageValidator.java b/src/main/java/PerfumeOnMe/spring/validation/validator/PageValidator.java new file mode 100644 index 0000000..782ce5c --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/validation/validator/PageValidator.java @@ -0,0 +1,13 @@ +package PerfumeOnMe.spring.validation.validator; + +import PerfumeOnMe.spring.validation.annotation.ValidPage; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class PageValidator implements ConstraintValidator { + + @Override + public boolean isValid(Integer value, ConstraintValidatorContext context) { + return value != null && value >= 0; + } +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/validation/validator/SizeValidator.java b/src/main/java/PerfumeOnMe/spring/validation/validator/SizeValidator.java new file mode 100644 index 0000000..a84ee19 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/validation/validator/SizeValidator.java @@ -0,0 +1,13 @@ +package PerfumeOnMe.spring.validation.validator; + +import PerfumeOnMe.spring.validation.annotation.ValidSize; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class SizeValidator implements ConstraintValidator { + + @Override + public boolean isValid(Integer value, ConstraintValidatorContext context) { + return value != null && value >= 1; + } +} \ No newline at end of file From a92cb3f3c87831fb44a6ba672a75fa1b46761ae1 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Tue, 8 Jul 2025 17:16:00 +0900 Subject: [PATCH 096/339] =?UTF-8?q?[Fix]=20=ED=96=A5=EC=88=98=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EC=9A=94=EC=B2=AD=20DTO=20=EC=97=90=EC=84=9C=20siz?= =?UTF-8?q?e=20=EA=B0=92=EC=9D=80=20=EC=95=84=EC=A7=81=20=EB=AF=B8?= =?UTF-8?q?=EC=A0=95=EC=9D=B4=EB=AF=80=EB=A1=9C=20size=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=EC=97=90=20=EA=B0=92=20=ED=95=A0=EB=8B=B9=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/web/dto/fragrance/FragranceRequestDTO.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceRequestDTO.java index c95fc9f..66a190d 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceRequestDTO.java @@ -1,6 +1,8 @@ package PerfumeOnMe.spring.web.dto.fragrance; import PerfumeOnMe.spring.validation.annotation.ValidKeyword; +import PerfumeOnMe.spring.validation.annotation.ValidPage; +import PerfumeOnMe.spring.validation.annotation.ValidSize; import lombok.Getter; import lombok.Setter; @@ -12,8 +14,10 @@ public static class FragranceSearchRequest { @ValidKeyword private String keyword; + @ValidPage private int page; - private Integer size = 12; // default + @ValidSize + private Integer size; } } From c4e285e970817b7e3b5439e02a09420400a41478 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Tue, 8 Jul 2025 17:31:50 +0900 Subject: [PATCH 097/339] =?UTF-8?q?[Fix]=20=EC=A3=BC=EC=84=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B0=8F=20size=20=ED=95=84=EB=93=9C=20=EC=9E=90?= =?UTF-8?q?=EB=A3=8C=ED=98=95=EC=9D=84=20Integer=20->=20int(#16)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/web/dto/fragrance/FragranceRequestDTO.java | 4 +++- .../spring/web/dto/fragrance/FragranceResponseDTO.java | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceRequestDTO.java index 66a190d..9138c70 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceRequestDTO.java @@ -7,6 +7,8 @@ import lombok.Setter; public class FragranceRequestDTO { + + // 향수 검색 응답 DTO @Getter @Setter public static class FragranceSearchRequest { @@ -18,6 +20,6 @@ public static class FragranceSearchRequest { private int page; @ValidSize - private Integer size; + private int size; } } diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java index e50d45c..65cf635 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java @@ -9,7 +9,7 @@ public class FragranceResponseDTO { - // 향수 상세 DTO + // 향수 상세 응답 DTO @Getter @Builder @AllArgsConstructor @@ -68,7 +68,7 @@ public static class FragranceTypeDto { } } - // 향수 검색 DTO + // 향수 검색 응답 DTO @Getter @Builder @AllArgsConstructor From 46e05f9d6477f0695494222f7fa4774540436ca8 Mon Sep 17 00:00:00 2001 From: bulee5328 Date: Tue, 8 Jul 2025 19:02:55 +0900 Subject: [PATCH 098/339] [Feature] setting-entity-pbti (#14) --- .../java/PerfumeOnMe/spring/domain/PBTI.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/domain/PBTI.java b/src/main/java/PerfumeOnMe/spring/domain/PBTI.java index d8426f8..25093d7 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/PBTI.java +++ b/src/main/java/PerfumeOnMe/spring/domain/PBTI.java @@ -9,6 +9,7 @@ import PerfumeOnMe.spring.domain.base.BaseEntity; import PerfumeOnMe.spring.domain.mapping.RecommendedFragrance; import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; @@ -41,6 +42,30 @@ public class PBTI extends BaseEntity { @JoinColumn(name = "user_id") private User user; + @Column(nullable = false, length = 50) + private String savedName; + + @Column(columnDefinition = "json", nullable = false) + private String answers; + + @Column(columnDefinition = "text", nullable = false) + private String recommendation; + + @Column(columnDefinition = "json", nullable = false) + private String keywords; + + @Column(columnDefinition = "json", nullable = false) + private String style; + + @Column(columnDefinition = "json", nullable = false) + private String scentProfile; + + @Column(columnDefinition = "text", nullable = false) + private String summary; + + @Column(columnDefinition = "json", nullable = false) + private String perfumes; + @OneToMany(mappedBy = "pbti", cascade = CascadeType.ALL) @Builder.Default private List RecommendedFragranceList = new ArrayList<>(); From 7e8d5cfc90e2cfdafd94fc70b69cc64f8883c910 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Tue, 8 Jul 2025 21:56:24 +0900 Subject: [PATCH 099/339] =?UTF-8?q?[Feature]=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=EB=90=9C=20=EC=95=A1=EC=84=B8=EC=8A=A4=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=EC=9D=84=20=EA=B4=80=EB=A6=AC=ED=95=98=EB=8A=94=20Tok?= =?UTF-8?q?enManager=20=EC=B6=94=EA=B0=80=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../manager/LogoutAccessTokenManager.java | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/auth/manager/LogoutAccessTokenManager.java diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/manager/LogoutAccessTokenManager.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/manager/LogoutAccessTokenManager.java new file mode 100644 index 0000000..4d6bf78 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/manager/LogoutAccessTokenManager.java @@ -0,0 +1,42 @@ +package PerfumeOnMe.spring.config.security.auth.manager; + +import java.time.Duration; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class LogoutAccessTokenManager { + + private static final String LOGOUT_ACCESS_TOKEN_PREFIX = "Logout:"; + private final StringRedisTemplate stringRedisTemplate; + private final JwtTokenProvider jwtTokenProvider; + + public void saveLogoutAccessToken(String loginId, String accessToken) { + long expirationMillis = jwtTokenProvider.getExpiration(accessToken); + long nowMillis = System.currentTimeMillis(); + long durationMillis = expirationMillis - nowMillis; + + String key = LOGOUT_ACCESS_TOKEN_PREFIX + loginId; + stringRedisTemplate.opsForValue().set(key, accessToken, Duration.ofMillis(durationMillis)); + } + + public boolean findLogoutAccessToken(String loginId) { + String key = LOGOUT_ACCESS_TOKEN_PREFIX + loginId; + return stringRedisTemplate.hasKey(key); + } + + public String getLogoutAccessToken(String loginId) { + String key = LOGOUT_ACCESS_TOKEN_PREFIX + loginId; + return stringRedisTemplate.opsForValue().get(key); + } + + public void deleteLogoutAccessToken(String loginId) { + String key = LOGOUT_ACCESS_TOKEN_PREFIX + loginId; + stringRedisTemplate.delete(key); + } +} From cb5fbd4356e2cd25b5a6369838ce61610f38afcc Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Tue, 8 Jul 2025 21:59:39 +0900 Subject: [PATCH 100/339] =?UTF-8?q?[Feature]=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20API=20=EA=B5=AC=ED=98=84=20-=20=EC=95=A1=EC=84=B8?= =?UTF-8?q?=EC=8A=A4=20=ED=86=A0=ED=81=B0=20=EB=B8=94=EB=9E=99=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=ED=99=94=20=EB=B0=8F=20=EB=A6=AC=ED=94=84?= =?UTF-8?q?=EB=A0=88=EC=8B=9C=20=ED=86=A0=ED=81=B0=20=EC=82=AD=EC=A0=9C=20?= =?UTF-8?q?(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apiPayload/code/status/ErrorStatus.java | 2 +- .../spring/service/user/UserService.java | 3 +++ .../spring/service/user/UserServiceImpl.java | 24 +++++++++++++++++++ .../spring/web/controller/UserController.java | 21 +++++++++++++--- 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index 980a3ce..4656217 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -28,7 +28,7 @@ public enum ErrorStatus implements BaseErrorCode { INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "TOKEN4001", "유효하지 않은 토큰입니다."), REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "TOKEN4002", "해당 리프레시 토큰이 존재하지 않습니다."), EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "TOKEN4003", "만료된 토큰입니다."), - LOGOUT_ACCESS_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "TOKEN4004", "로그아웃한 액세스 토큰이 존재하지 않습니다."), + LOGOUT_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "TOKEN4004", "로그아웃한 액세스 토큰입니다."), MALFORMED_TOKEN(HttpStatus.UNAUTHORIZED, "TOKEN4005", "토큰 구조가 잘못됐습니다."), UNSUPPORTED_TOKEN(HttpStatus.UNAUTHORIZED, "TOKEN4006", "지원하지 않는 토큰 형식입니다."), INVALID_SIGNATURE(HttpStatus.UNAUTHORIZED, "TOKEN4007", "토큰의 서명이 잘못됐습니다."), diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserService.java b/src/main/java/PerfumeOnMe/spring/service/user/UserService.java index 0b66fee..748be30 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserService.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserService.java @@ -3,6 +3,7 @@ import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; import PerfumeOnMe.spring.web.dto.user.UserRequestDTO; import PerfumeOnMe.spring.web.dto.user.UserResponseDTO; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; public interface UserService { @@ -10,4 +11,6 @@ public interface UserService { UserResponseDTO.SignupResult signup(UserRequestDTO.Signup request); AuthResponseDTO.RefreshToken reissue(String refreshToken, HttpServletResponse response); + + void logout(HttpServletRequest request); } diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java index d78cbb0..cc86c9c 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java @@ -11,6 +11,7 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.config.security.auth.manager.LogoutAccessTokenManager; import PerfumeOnMe.spring.config.security.auth.manager.RefreshTokenManager; import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; import PerfumeOnMe.spring.config.security.auth.token.JwtAuthenticationToken; @@ -19,6 +20,7 @@ import PerfumeOnMe.spring.repository.user.UserRepository; import PerfumeOnMe.spring.web.dto.user.UserRequestDTO; import PerfumeOnMe.spring.web.dto.user.UserResponseDTO; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -31,6 +33,7 @@ public class UserServiceImpl implements UserService { private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenManager refreshTokenManager; + private final LogoutAccessTokenManager logoutAccessTokenManager; private final UserDetailsService userDetailsService; // 사용자 회원가입 @@ -87,4 +90,25 @@ public AuthResponseDTO.RefreshToken reissue(String reqRefreshToken, HttpServletR return refreshTokenDTO; } + + // 사용자 로그아웃 - 액세스 토큰과 리프레시 토큰 블랙리스트화 + @Override + public void logout(HttpServletRequest request) { + + // 요청에서 액세스 토큰 추출 및 유효성 검증 + String accessToken = jwtTokenProvider.resolveToken(request); + jwtTokenProvider.validateToken(accessToken); + + // 토큰에서 loginId 추출 및 사용자 검증 + String loginId = jwtTokenProvider.getSubject(accessToken); + userDetailsService.loadUserByUsername(loginId); + + // 액세스 토큰 블랙리스트화 + logoutAccessTokenManager.saveLogoutAccessToken(loginId, accessToken); + + // 리프레시 토큰 삭제 + if (refreshTokenManager.findRefreshToken(loginId)) { + refreshTokenManager.deleteRefreshToken(loginId); + } + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java index c1e681f..7584a02 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java @@ -19,6 +19,7 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -55,16 +56,30 @@ public ResponseEntity> signup( description = "헤더에 입력한 Refresh-Token으로 새로운 액세스 토큰과 리프레시 토큰을 발급하는 API입니다.", responses = { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "", description = "") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4002", description = "해당 리프레시 토큰이 존재하지 않습니다.") }, parameters = { @Parameter(name = "refreshToken", description = "Refresh-Token 헤더에 리프레시 토큰을 입력해 주세요.") } ) public ResponseEntity> reissue( - @RequestHeader(name = "Refresh-Token") String refreshToken, - HttpServletResponse response) { + @RequestHeader(name = "Refresh-Token") String refreshToken, HttpServletResponse response) { AuthResponseDTO.RefreshToken result = userService.reissue(refreshToken, response); return ResponseEntity.ok().body(ApiResponse.onSuccess(result)); } + + @PostMapping("/logout") + @Operation( + summary = "로그아웃 API", + description = "사용자의 액세스 토큰과 리프레시 토큰을 블랙리스트화하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다."), + } + ) + public ResponseEntity> logout(HttpServletRequest request) { + userService.logout(request); + return ResponseEntity.ok().body(ApiResponse.onSuccess(null)); + } } From 1a91c67c6f0fd1c1b2eb945e63f537d5855791dd Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Tue, 8 Jul 2025 22:03:26 +0900 Subject: [PATCH 101/339] =?UTF-8?q?[Fix]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=84=B1=EA=B3=B5=20=EC=8B=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EC=9D=98=20=EA=B8=B0=EC=A1=B4=20=EC=95=A1=EC=84=B8=EC=8A=A4=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=EC=9D=B4=20=EB=B8=94=EB=9E=99=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=ED=99=94=EB=90=9C=20=EA=B2=BD=EC=9A=B0=20?= =?UTF-8?q?=ED=95=B4=EC=A0=9C=ED=95=98=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/security/auth/filter/JwtLoginFilter.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java index 765bc4e..21e66bc 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java @@ -22,6 +22,7 @@ import PerfumeOnMe.spring.apiPayload.exception.GeneralException; import PerfumeOnMe.spring.config.security.auth.dto.AuthRequestDTO; import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.config.security.auth.manager.LogoutAccessTokenManager; import PerfumeOnMe.spring.config.security.auth.manager.RefreshTokenManager; import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; import jakarta.servlet.FilterChain; @@ -41,6 +42,7 @@ public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter { private final JwtTokenProvider jwtTokenProvider; private final RefreshTokenManager refreshTokenManager; + private final LogoutAccessTokenManager logoutAccessTokenManager; private final ObjectMapper mapper = new ObjectMapper(); @@ -70,8 +72,15 @@ public Authentication attemptAuthentication(HttpServletRequest request, HttpServ protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { - // 토큰 생성 및 DTO에 담기 + // Authentication에서 principal String 추출 String loginId = authResult.getName(); + + // 사용자의 로그아웃 액세스 토큰이 존재하는 경우 삭제 + if (logoutAccessTokenManager.findLogoutAccessToken(loginId)) { + logoutAccessTokenManager.deleteLogoutAccessToken(loginId); + } + + // 토큰 생성 및 DTO에 담기 String accessToken = jwtTokenProvider.createAccessToken(authResult); String refreshToken = jwtTokenProvider.createRefreshToken(authResult); AuthResponseDTO.RefreshToken refreshTokenDTO = AuthResponseDTO From 1306a0a71cc4f01bbb740f78ff59bc08a7265734 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Tue, 8 Jul 2025 22:04:00 +0900 Subject: [PATCH 102/339] =?UTF-8?q?[Fix]=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=EB=90=9C=20=EC=95=A1=EC=84=B8=EC=8A=A4=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=EC=9D=B8=20=EA=B2=BD=EC=9A=B0=20JWT=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=EC=9D=84=20=EC=8B=9C=EB=8F=84=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/auth/filter/JwtAuthenticationFilter.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtAuthenticationFilter.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtAuthenticationFilter.java index 57286d4..b80396a 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtAuthenticationFilter.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtAuthenticationFilter.java @@ -9,6 +9,9 @@ import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.config.security.auth.manager.LogoutAccessTokenManager; import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; import PerfumeOnMe.spring.config.security.auth.token.JwtAuthenticationToken; import jakarta.servlet.FilterChain; @@ -27,6 +30,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; private final AuthenticationManager authenticationManager; + private final LogoutAccessTokenManager logoutAccessTokenManager; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, @@ -36,6 +40,12 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse if (StringUtils.hasText(accessToken)) { String loginId = jwtTokenProvider.getSubject(accessToken); + + // 로그아웃된 액세스 토큰이 아닌 경우에만 JWT 인증 시도 및 설정 + if (logoutAccessTokenManager.findLogoutAccessToken(loginId)) { + throw new GeneralException(ErrorStatus.LOGOUT_ACCESS_TOKEN); + } + JwtAuthenticationToken authRequest = new JwtAuthenticationToken(loginId, accessToken); Authentication authResult = authenticationManager.authenticate(authRequest); SecurityContextHolder.getContext().setAuthentication(authResult); From 42f9ae9e52ff8022155c4a653d8a6abaad026dc6 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Tue, 8 Jul 2025 22:07:43 +0900 Subject: [PATCH 103/339] =?UTF-8?q?[Fix]=20Controller=20parameters=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/web/controller/UserController.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java index 7584a02..ebc9ab1 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java @@ -15,7 +15,6 @@ import PerfumeOnMe.spring.web.dto.user.UserRequestDTO; import PerfumeOnMe.spring.web.dto.user.UserResponseDTO; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; @@ -58,9 +57,6 @@ public ResponseEntity> signup( @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다."), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4002", description = "해당 리프레시 토큰이 존재하지 않습니다.") - }, - parameters = { - @Parameter(name = "refreshToken", description = "Refresh-Token 헤더에 리프레시 토큰을 입력해 주세요.") } ) public ResponseEntity> reissue( From fed7f2ccb692a7914818ba92b3ff5c1debc4cbac Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Tue, 8 Jul 2025 22:35:51 +0900 Subject: [PATCH 104/339] =?UTF-8?q?[Feature]=20=ED=9A=8C=EC=9B=90=ED=83=88?= =?UTF-8?q?=ED=87=B4=20API=20=EA=B5=AC=ED=98=84=20-=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=ED=9B=84=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/service/user/UserService.java | 4 +++- .../spring/service/user/UserServiceImpl.java | 12 +++++++++++- .../spring/web/controller/UserController.java | 15 +++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserService.java b/src/main/java/PerfumeOnMe/spring/service/user/UserService.java index 748be30..106103c 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserService.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserService.java @@ -12,5 +12,7 @@ public interface UserService { AuthResponseDTO.RefreshToken reissue(String refreshToken, HttpServletResponse response); - void logout(HttpServletRequest request); + String logout(HttpServletRequest request); + + void deleteUser(HttpServletRequest request); } diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java index cc86c9c..0a76b58 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java @@ -93,7 +93,7 @@ public AuthResponseDTO.RefreshToken reissue(String reqRefreshToken, HttpServletR // 사용자 로그아웃 - 액세스 토큰과 리프레시 토큰 블랙리스트화 @Override - public void logout(HttpServletRequest request) { + public String logout(HttpServletRequest request) { // 요청에서 액세스 토큰 추출 및 유효성 검증 String accessToken = jwtTokenProvider.resolveToken(request); @@ -110,5 +110,15 @@ public void logout(HttpServletRequest request) { if (refreshTokenManager.findRefreshToken(loginId)) { refreshTokenManager.deleteRefreshToken(loginId); } + + return loginId; + } + + // 회원탈퇴 - 로그아웃 진행 후 사용자 삭제 + @Override + public void deleteUser(HttpServletRequest request) { + String loginId = logout(request); + Optional findUser = userRepository.findUserByLoginId(loginId); + findUser.ifPresent(userRepository::delete); } } diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java index ebc9ab1..57c2645 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java @@ -2,6 +2,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; @@ -78,4 +79,18 @@ public ResponseEntity> logout(HttpServletRequest request) { userService.logout(request); return ResponseEntity.ok().body(ApiResponse.onSuccess(null)); } + + @DeleteMapping("/me") + @Operation( + summary = "회원탈퇴 API", + description = "사용자의 액세스 토큰과 리프레시 토큰을 블랙리스트화하고, 사용자를 삭제하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다."), + } + ) + public ResponseEntity> deleteUser(HttpServletRequest request) { + userService.deleteUser(request); + return ResponseEntity.ok().body(ApiResponse.onSuccess(null)); + } } From 010bc5f6916484fcc4e8cb656b900e69112febe5 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Wed, 9 Jul 2025 10:08:28 +0900 Subject: [PATCH 105/339] =?UTF-8?q?[Feature]=20=ED=82=A4=EC=9B=8C=EB=93=9C?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20ENUM=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/domain/enums/KeywordCategory.java | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/domain/enums/KeywordCategory.java diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/KeywordCategory.java b/src/main/java/PerfumeOnMe/spring/domain/enums/KeywordCategory.java new file mode 100644 index 0000000..9331ea5 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/KeywordCategory.java @@ -0,0 +1,5 @@ +package PerfumeOnMe.spring.domain.enums; + +public enum KeywordCategory { + AMBIENCE, STYLE, GENDER, SEASON, CHARACTER +} From 8f62229872ac9584dd34992c940e18ff9859caca Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Wed, 9 Jul 2025 10:20:47 +0900 Subject: [PATCH 106/339] =?UTF-8?q?[Feature]=20ImageKeywordDescription=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=83=9D=EC=84=B1=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ImageKeywordDescription.java | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/domain/ImageKeywordDescription.java diff --git a/src/main/java/PerfumeOnMe/spring/domain/ImageKeywordDescription.java b/src/main/java/PerfumeOnMe/spring/domain/ImageKeywordDescription.java new file mode 100644 index 0000000..f2b6d98 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/ImageKeywordDescription.java @@ -0,0 +1,41 @@ +package PerfumeOnMe.spring.domain; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.domain.enums.KeywordCategory; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@DynamicInsert +@DynamicUpdate +@Table(name = "imagekeyword_descriptions") +public class ImageKeywordDescription extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(length = 20, nullable = false) + private String keyword; + + @Column(nullable = false) + private KeywordCategory category; + + @Column(nullable = false, columnDefinition = "TEXT") + private String description; +} From c242e175768251e5ea240148f3f5e707a279e73e Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Wed, 9 Jul 2025 11:19:30 +0900 Subject: [PATCH 107/339] =?UTF-8?q?[Feature]=20Workshop=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=83=9D=EC=84=B1=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/domain/Workshop.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/domain/Workshop.java b/src/main/java/PerfumeOnMe/spring/domain/Workshop.java index 45c8588..fbfbe1c 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Workshop.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Workshop.java @@ -4,6 +4,7 @@ import org.hibernate.annotations.DynamicUpdate; import PerfumeOnMe.spring.domain.base.BaseEntity; +import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; @@ -34,4 +35,43 @@ public class Workshop extends BaseEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") private User user; + + @Column(length = 50, nullable = false) + private String savedName; + + @Column(length = 40, nullable = false) + private String baseNote; + + @Column(nullable = false) + private Long baseNoteVolume; + + @Column(length = 40, nullable = false) + private String middleNote; + + @Column(nullable = false) + private Long middleNoteVolume; + + @Column(length = 40, nullable = false) + private String topNote; + + @Column(nullable = false) + private Long topNoteVolume; + + @Column(columnDefinition = "TEXT", nullable = false) + private String keywordSummary; + + @Column(columnDefinition = "TEXT", nullable = false) + private String firstImpression; + + @Column(columnDefinition = "TEXT", nullable = false) + private String centerImpression; + + @Column(columnDefinition = "TEXT", nullable = false) + private String lastImpression; + + @Column(columnDefinition = "TEXT", nullable = false) + private String tendency; + + @Column(columnDefinition = "TEXT", nullable = false) + private String recommendedFragranceJson; } From b399a02db6269111a0f8323b9eec7b062590c240 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Wed, 9 Jul 2025 12:53:50 +0900 Subject: [PATCH 108/339] =?UTF-8?q?[Fix]=20=EC=9E=84=EC=8B=9C=EB=A1=9C=20J?= =?UTF-8?q?WT=20=ED=86=A0=ED=81=B0=20=EC=9D=B8=EC=A6=9D=20=ED=95=B4?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/config/security/SecurityConfig.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java b/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java index fab0c5a..dcbfb81 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java @@ -29,7 +29,8 @@ public class SecurityConfig { // 인증 여부를 확인하지 않을 경로 지정 public static final String[] AUTH_WHITELIST = { "/v3/api-docs/**", "/swagger-resources/**", "/swagger-ui.html", "/swagger-ui/**", - "/swagger/**", "/users/signup", "/auth/login", "/auth/social/kakao", "/users/reissue" + "/swagger/**", "/users/signup", "/auth/login", "/auth/social/kakao", "/users/reissue", + "/health" }; private final JwtAuthenticationFilter JwtAuthenticationFilter; private final JwtExceptionHandlerFilter JwtExceptionHandlerFilter; @@ -43,7 +44,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 요청 경로별 인증 확인 설정 .authorizeHttpRequests(auth -> auth .requestMatchers(AUTH_WHITELIST).permitAll() - .anyRequest().authenticated() + .anyRequest().permitAll() // 개발 진행할 때 임시로 풀어두기 -> 나중에 authenticated()로 변경 ) // filter 레벨에서 발생하는 예외 핸들러 설정 .exceptionHandling(exception -> exception From 81cc22f353a6b60f3aa56959bd457703ddfdbaaa Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Wed, 9 Jul 2025 13:12:35 +0900 Subject: [PATCH 109/339] =?UTF-8?q?[Feature]=20=ED=82=A4=EC=9B=8C=EB=93=9C?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20ENUM=EC=B6=94=EA=B0=80=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/domain/enums/Ambience.java | 23 +++++++++++++++++++ .../spring/domain/enums/Character.java | 23 +++++++++++++++++++ .../spring/domain/enums/Gender.java | 16 +++++++++++++ .../spring/domain/enums/Season.java | 17 ++++++++++++++ .../spring/domain/enums/Style.java | 23 +++++++++++++++++++ 5 files changed, 102 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/domain/enums/Ambience.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/enums/Character.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/enums/Gender.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/enums/Season.java create mode 100644 src/main/java/PerfumeOnMe/spring/domain/enums/Style.java diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Ambience.java b/src/main/java/PerfumeOnMe/spring/domain/enums/Ambience.java new file mode 100644 index 0000000..5664d8f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/Ambience.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.domain.enums; + +import lombok.Getter; + +@Getter +public enum Ambience { + SOPHISTICATED("세련된"), + CUTE("귀여운"), + CALM("차분한"), + MATURE("성숙한"), + LUXURIOUS("럭셔리한"), + ELEGANT("시크한"), + FRESH("신비로운"), + BRIGHT("밝은"), + LIVELY("몽환적인"), + GRACEFUL("우아한"); + + private final String displayName; + + Ambience(String displayName) { + this.displayName = displayName; + } +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Character.java b/src/main/java/PerfumeOnMe/spring/domain/enums/Character.java new file mode 100644 index 0000000..3c282f4 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/Character.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.domain.enums; + +import lombok.Getter; + +@Getter +public enum Character { + QUIET("조용한"), + LOGICAL("논리적인"), + STRONG("개성 강한"), + CHARISMATIC("카리스마 있는"), + CAUTIOUS("신중한"), + LIVELY("활발한"), + WARM("따뜻한"), + EMOTIONAL("감성적인"), + FRIENDLY("친근한"), + PASSIONATE("쾌활한"); + + private final String displayName; + + Character(String displayName) { + this.displayName = displayName; + } +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Gender.java b/src/main/java/PerfumeOnMe/spring/domain/enums/Gender.java new file mode 100644 index 0000000..73c0341 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/Gender.java @@ -0,0 +1,16 @@ +package PerfumeOnMe.spring.domain.enums; + +import lombok.Getter; + +@Getter +public enum Gender { + FEMININE("여성스러운"), + MASCULINE("남성적인"), + NEUTRAL("중성적인"); + + private final String displayName; + + Gender(String displayName) { + this.displayName = displayName; + } +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Season.java b/src/main/java/PerfumeOnMe/spring/domain/enums/Season.java new file mode 100644 index 0000000..200da6e --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/Season.java @@ -0,0 +1,17 @@ +package PerfumeOnMe.spring.domain.enums; + +import lombok.Getter; + +@Getter +public enum Season { + SPRING("봄"), + SUMMER("여름"), + AUTUMN("가을"), + WINTER("겨울"); + + private final String displayName; + + Season(String displayName) { + this.displayName = displayName; + } +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Style.java b/src/main/java/PerfumeOnMe/spring/domain/enums/Style.java new file mode 100644 index 0000000..b2fc0c5 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/Style.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.domain.enums; + +import lombok.Getter; + +@Getter +public enum Style { + UNIQUE("유니크한"), + STREET("스트릿한"), + ROMANTIC("로맨틱한"), + HIPHOP("힙한"), + MODERN("모던한"), + CLASSIC("클래식한"), + VINTAGE("빈티지한"), + CASUAL("캐주얼한"), + MINIMAL("미니멀한"), + RETRO("레트로한"); + + private final String displayName; + + Style(String displayName) { + this.displayName = displayName; + } +} From d489d5f6eb97992f02d9ff3297e177dac9361c0a Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Wed, 9 Jul 2025 13:15:38 +0900 Subject: [PATCH 110/339] =?UTF-8?q?[Feature]=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=EC=84=A4=EB=AA=85=20=EC=97=94?= =?UTF-8?q?=ED=84=B0=ED=8B=B0=20@Enumerated=EC=B6=94=EA=B0=80(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/domain/ImageKeywordDescription.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/domain/ImageKeywordDescription.java b/src/main/java/PerfumeOnMe/spring/domain/ImageKeywordDescription.java index f2b6d98..9d11706 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/ImageKeywordDescription.java +++ b/src/main/java/PerfumeOnMe/spring/domain/ImageKeywordDescription.java @@ -7,6 +7,8 @@ import PerfumeOnMe.spring.domain.enums.KeywordCategory; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -33,6 +35,7 @@ public class ImageKeywordDescription extends BaseEntity { @Column(length = 20, nullable = false) private String keyword; + @Enumerated(EnumType.STRING) @Column(nullable = false) private KeywordCategory category; From d7c79aca4002a843a3069d37e38dec8a8f131bae Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Wed, 9 Jul 2025 13:27:40 +0900 Subject: [PATCH 111/339] =?UTF-8?q?[Feature]=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=97=94=ED=84=B0=ED=8B=B0=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/domain/ImageKeyword.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java b/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java index 0329ecc..274f7d4 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java +++ b/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java @@ -4,7 +4,15 @@ import org.hibernate.annotations.DynamicUpdate; import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.domain.enums.Ambience; +import PerfumeOnMe.spring.domain.enums.Character; +import PerfumeOnMe.spring.domain.enums.Gender; +import PerfumeOnMe.spring.domain.enums.Season; +import PerfumeOnMe.spring.domain.enums.Style; +import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -35,4 +43,39 @@ public class ImageKeyword extends BaseEntity { @JoinColumn(name = "user_id") private User user; + @Column(length = 50, nullable = false) + private String savedName; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Ambience ambience; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Style style; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Gender gender; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Season season; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Character character; + + @Column(nullable = false, columnDefinition = "TEXT") + private String imageUrl; + + @Column(nullable = false, columnDefinition = "TEXT") + private String scenario; + + @Column(nullable = false, columnDefinition = "TEXT") + private String keywordDescription; + + @Column(nullable = false, columnDefinition = "TEXT") + private String recommendedFragranceJson; + } From a7475508c2f266683ca754f3857ed89a7f8edb2d Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Wed, 9 Jul 2025 15:31:50 +0900 Subject: [PATCH 112/339] =?UTF-8?q?[Feature]=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=84=B1=EA=B2=A9=20?= =?UTF-8?q?=ED=98=B8=EC=B9=AD=EB=B3=80=EA=B2=BD=20Character->Personality(#?= =?UTF-8?q?22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java | 4 ++-- .../java/PerfumeOnMe/spring/domain/enums/KeywordCategory.java | 2 +- .../spring/domain/enums/{Character.java => Personality.java} | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) rename src/main/java/PerfumeOnMe/spring/domain/enums/{Character.java => Personality.java} (86%) diff --git a/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java b/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java index 274f7d4..f3ade44 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java +++ b/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java @@ -5,8 +5,8 @@ import PerfumeOnMe.spring.domain.base.BaseEntity; import PerfumeOnMe.spring.domain.enums.Ambience; -import PerfumeOnMe.spring.domain.enums.Character; import PerfumeOnMe.spring.domain.enums.Gender; +import PerfumeOnMe.spring.domain.enums.Personality; import PerfumeOnMe.spring.domain.enums.Season; import PerfumeOnMe.spring.domain.enums.Style; import jakarta.persistence.Column; @@ -64,7 +64,7 @@ public class ImageKeyword extends BaseEntity { @Enumerated(EnumType.STRING) @Column(nullable = false) - private Character character; + private Personality personality; @Column(nullable = false, columnDefinition = "TEXT") private String imageUrl; diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/KeywordCategory.java b/src/main/java/PerfumeOnMe/spring/domain/enums/KeywordCategory.java index 9331ea5..f76088a 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/KeywordCategory.java +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/KeywordCategory.java @@ -1,5 +1,5 @@ package PerfumeOnMe.spring.domain.enums; public enum KeywordCategory { - AMBIENCE, STYLE, GENDER, SEASON, CHARACTER + AMBIENCE, STYLE, GENDER, SEASON, PERSONALITY } diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Character.java b/src/main/java/PerfumeOnMe/spring/domain/enums/Personality.java similarity index 86% rename from src/main/java/PerfumeOnMe/spring/domain/enums/Character.java rename to src/main/java/PerfumeOnMe/spring/domain/enums/Personality.java index 3c282f4..adaac29 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/Character.java +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/Personality.java @@ -3,7 +3,7 @@ import lombok.Getter; @Getter -public enum Character { +public enum Personality { QUIET("조용한"), LOGICAL("논리적인"), STRONG("개성 강한"), @@ -17,7 +17,7 @@ public enum Character { private final String displayName; - Character(String displayName) { + Personality(String displayName) { this.displayName = displayName; } } From 0f3e1be2cc66f1df4c867d8581ef43c40fec10fe Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Wed, 9 Jul 2025 21:48:42 +0900 Subject: [PATCH 113/339] =?UTF-8?q?[Fix]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=8B=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90=20PK=EB=8F=84=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/config/security/auth/dto/AuthResponseDTO.java | 3 ++- .../config/security/auth/filter/JwtLoginFilter.java | 8 +++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthResponseDTO.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthResponseDTO.java index a52f927..815a780 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthResponseDTO.java @@ -11,7 +11,8 @@ public class AuthResponseDTO { @Builder @AllArgsConstructor @NoArgsConstructor - public static class RefreshToken { + public static class LoginResult { private String refreshToken; + private Long userId; } } diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java index 21e66bc..edab849 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java @@ -25,6 +25,7 @@ import PerfumeOnMe.spring.config.security.auth.manager.LogoutAccessTokenManager; import PerfumeOnMe.spring.config.security.auth.manager.RefreshTokenManager; import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; +import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -74,6 +75,7 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR // Authentication에서 principal String 추출 String loginId = authResult.getName(); + Long userId = ((CustomUserDetails)authResult.getPrincipal()).getUserId(); // 사용자의 로그아웃 액세스 토큰이 존재하는 경우 삭제 if (logoutAccessTokenManager.findLogoutAccessToken(loginId)) { @@ -83,9 +85,9 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR // 토큰 생성 및 DTO에 담기 String accessToken = jwtTokenProvider.createAccessToken(authResult); String refreshToken = jwtTokenProvider.createRefreshToken(authResult); - AuthResponseDTO.RefreshToken refreshTokenDTO = AuthResponseDTO - .RefreshToken.builder() + AuthResponseDTO.LoginResult loginResultDTO = AuthResponseDTO.LoginResult.builder() .refreshToken(refreshToken) + .userId(userId) .build(); // 리프레시 토큰을 Redis에 저장 @@ -98,7 +100,7 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR response.setHeader("Authorization", "Bearer " + accessToken); // 응답 데이터 생성 및 작성 - ApiResponse res = ApiResponse.onSuccess(refreshTokenDTO); + ApiResponse res = ApiResponse.onSuccess(loginResultDTO); response.getWriter().write(mapper.writeValueAsString(res)); // SecurityContextHolder에 인증 설정 From 1abb3bc01deb5bf235605dc226a19e5d31176dc7 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Wed, 9 Jul 2025 21:49:12 +0900 Subject: [PATCH 114/339] =?UTF-8?q?[Fix]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20DTO=20=EC=9D=B4=EB=A6=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/PerfumeOnMe/spring/service/user/UserService.java | 2 +- .../PerfumeOnMe/spring/service/user/UserServiceImpl.java | 7 +++---- .../PerfumeOnMe/spring/web/controller/UserController.java | 4 ++-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserService.java b/src/main/java/PerfumeOnMe/spring/service/user/UserService.java index 106103c..79d5eaf 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserService.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserService.java @@ -10,7 +10,7 @@ public interface UserService { UserResponseDTO.SignupResult signup(UserRequestDTO.Signup request); - AuthResponseDTO.RefreshToken reissue(String refreshToken, HttpServletResponse response); + AuthResponseDTO.LoginResult reissue(String refreshToken, HttpServletResponse response); String logout(HttpServletRequest request); diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java index 0a76b58..cc71212 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java @@ -64,7 +64,7 @@ public UserResponseDTO.SignupResult signup(UserRequestDTO.Signup request) { // 리프레시 토큰으로 액세스 토큰과 리프레시 토큰 재발급 @Override - public AuthResponseDTO.RefreshToken reissue(String reqRefreshToken, HttpServletResponse response) { + public AuthResponseDTO.LoginResult reissue(String reqRefreshToken, HttpServletResponse response) { // 리프레시 토큰에서 Subject 추출 String loginId = jwtTokenProvider.getSubject(reqRefreshToken); @@ -74,8 +74,7 @@ public AuthResponseDTO.RefreshToken reissue(String reqRefreshToken, HttpServletR JwtAuthenticationToken request = new JwtAuthenticationToken(userDetails, null, userDetails.getAuthorities()); String accessToken = jwtTokenProvider.createAccessToken(request); String refreshToken = jwtTokenProvider.createRefreshToken(request); - AuthResponseDTO.RefreshToken refreshTokenDTO = AuthResponseDTO - .RefreshToken.builder() + AuthResponseDTO.LoginResult loginResultDTO = AuthResponseDTO.LoginResult.builder() .refreshToken(refreshToken) .build(); @@ -88,7 +87,7 @@ public AuthResponseDTO.RefreshToken reissue(String reqRefreshToken, HttpServletR response.setStatus(HttpServletResponse.SC_OK); response.setHeader("Authorization", "Bearer " + accessToken); - return refreshTokenDTO; + return loginResultDTO; } // 사용자 로그아웃 - 액세스 토큰과 리프레시 토큰 블랙리스트화 diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java index 57c2645..703db8d 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java @@ -60,9 +60,9 @@ public ResponseEntity> signup( @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4002", description = "해당 리프레시 토큰이 존재하지 않습니다.") } ) - public ResponseEntity> reissue( + public ResponseEntity> reissue( @RequestHeader(name = "Refresh-Token") String refreshToken, HttpServletResponse response) { - AuthResponseDTO.RefreshToken result = userService.reissue(refreshToken, response); + AuthResponseDTO.LoginResult result = userService.reissue(refreshToken, response); return ResponseEntity.ok().body(ApiResponse.onSuccess(result)); } From ac569a2989757b86f3ceaeb425491ef166ec42ee Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Thu, 10 Jul 2025 15:42:46 +0900 Subject: [PATCH 115/339] =?UTF-8?q?[Feature]=20ErrorStatus=20=EC=97=90=20?= =?UTF-8?q?=ED=96=A5=EC=88=98=20=ED=95=84=ED=84=B0=EB=A7=81=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20enum=20=EC=B6=94=EA=B0=80(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/apiPayload/code/status/ErrorStatus.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index 4656217..d3865ff 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -42,6 +42,14 @@ public enum ErrorStatus implements BaseErrorCode { // 향수 상세 페이지 에러 FRAGRANCE_NOT_FOUND(HttpStatus.BAD_REQUEST, "FRAGRANCE4001", "해당 ID에 해당하는 향수를 찾을 수 없습니다."), + // 향수 필터링 에러 + INVALID_GENDER(HttpStatus.BAD_REQUEST, "FILTER4001", "유효하지 않은 성별입니다."), + INVALID_FRAGRANCE_TYPE(HttpStatus.BAD_REQUEST, "FILTER4002", "유효하지 않은 향수 타입입니다."), + INVALID_NOTE_ID(HttpStatus.BAD_REQUEST, "FILTER4003", "유효하지 않은 노트 ID 입니다."), + INVALID_SEASON_ID(HttpStatus.BAD_REQUEST, "FILTER4004", "유효하지 않은 계절 ID 입니다."), + INVALID_SITUATION_ID(HttpStatus.BAD_REQUEST, "FILTER4005", "유효하지 않은 장소 ID 입니다."), + INVALID_PRICE_RANGE(HttpStatus.BAD_REQUEST, "FILTER4006", "가격 범위가 올바르지 않습니다."), + // 예시,,, ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); From 614ffb1ebd0815335099e4658a8b7d0a04c99967 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Thu, 10 Jul 2025 15:43:31 +0900 Subject: [PATCH 116/339] =?UTF-8?q?[Feature]=20=ED=96=A5=EC=88=98=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EB=A7=81=20=EC=9A=94=EC=B2=AD=20DTO=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/fragrance/FragranceRequestDTO.java | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceRequestDTO.java index 9138c70..2d732f5 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceRequestDTO.java @@ -8,7 +8,7 @@ public class FragranceRequestDTO { - // 향수 검색 응답 DTO + // 향수 검색 요청 DTO @Getter @Setter public static class FragranceSearchRequest { @@ -22,4 +22,32 @@ public static class FragranceSearchRequest { @ValidSize private int size; } + + // 향수 필터링 요청 DTO + @Getter + @Setter + public static class FragranceFilterRequest { + + private Long noteCategoryId; + + private String gender; + + private String fragranceType; + + private Long situationId; + + private Long seasonId; + + private Integer priceMin; + + private Integer priceMax; + + @ValidPage + private Integer page; + + @ValidSize + private Integer size; + + } + } From 8e2783146e109a3a187faea0e5b16a651f831e50 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Thu, 10 Jul 2025 15:44:02 +0900 Subject: [PATCH 117/339] =?UTF-8?q?[Feature]=20=ED=96=A5=EC=88=98=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EB=A7=81=20=EC=9D=91=EB=8B=B5=20DTO=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/dto/fragrance/FragranceResponseDTO.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java index 65cf635..2eec1df 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java @@ -68,7 +68,7 @@ public static class FragranceTypeDto { } } - // 향수 검색 응답 DTO + // 향수 검색,필터링 응답 DTO (각 향수 단건) @Getter @Builder @AllArgsConstructor @@ -81,6 +81,15 @@ public static class FragranceSearchResult { private String imageUrl; } + // 향수, 검색 필터링 응답 DTO (최종 응답) + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class FragranceSearchFinalResult { + private List content; + private boolean hasNext; + } } From fd2cda7dc7024ff3737d0adda42e1c2bd90620b1 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Thu, 10 Jul 2025 15:44:39 +0900 Subject: [PATCH 118/339] =?UTF-8?q?[Feature]=20=ED=96=A5=EC=88=98=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=EB=A7=81=20API=20=EA=B5=AC=ED=98=84(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/converter/FragranceConverter.java | 2 + .../fragrance/FragranceRepositoryCustom.java | 7 ++ .../fragrance/FragranceRepositoryImpl.java | 90 +++++++++++++++++++ .../service/fragrance/FragranceService.java | 5 ++ .../fragrance/FragranceServiceImpl.java | 66 +++++++++++++- .../web/controller/FragranceController.java | 38 +++++++- 6 files changed, 205 insertions(+), 3 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java index 2abaf8d..e58e16d 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java @@ -112,6 +112,8 @@ private static FragranceResponseDTO.FragranceDetailResult.NoteDto.NoteSection to /** * 향수 검색 API */ + + // 향수 반환 dto 변환 처리 public static FragranceResponseDTO.FragranceSearchResult toSearchResultDto(Fragrance fragrance) { Integer minPrice = fragrance.getFragrancePriceList().stream() .map(fp -> fp.getPrice().getPrice()) diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java index 430e33b..9e6c51c 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java @@ -6,9 +6,16 @@ import org.springframework.data.domain.Pageable; import PerfumeOnMe.spring.domain.Fragrance; +import PerfumeOnMe.spring.web.dto.fragrance.FragranceRequestDTO; public interface FragranceRepositoryCustom { + // 향수 상세 Optional findByIdWithAllDetails(Long id); + // 향수 검색 Page findBySearchKeyword(String keyword, Pageable pageable); + + // 향수 필터링 + Page findByFilter(FragranceRequestDTO.FragranceFilterRequest request, Pageable pageable); + } diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java index a524a32..16afcbb 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java @@ -4,18 +4,31 @@ import java.util.Optional; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; import org.springframework.data.support.PageableExecutionUtils; import org.springframework.stereotype.Repository; +import com.querydsl.core.BooleanBuilder; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.StringPath; import com.querydsl.jpa.impl.JPAQueryFactory; import PerfumeOnMe.spring.domain.Fragrance; import PerfumeOnMe.spring.domain.QFragrance; +import PerfumeOnMe.spring.domain.QLocation; +import PerfumeOnMe.spring.domain.QNote; import PerfumeOnMe.spring.domain.QPrice; +import PerfumeOnMe.spring.domain.QSeason; +import PerfumeOnMe.spring.domain.enums.FragranceGender; +import PerfumeOnMe.spring.domain.enums.FragranceType; +import PerfumeOnMe.spring.domain.mapping.QFragranceBaseNote; +import PerfumeOnMe.spring.domain.mapping.QFragranceLocation; +import PerfumeOnMe.spring.domain.mapping.QFragranceMiddleNote; import PerfumeOnMe.spring.domain.mapping.QFragrancePrice; +import PerfumeOnMe.spring.domain.mapping.QFragranceSeason; +import PerfumeOnMe.spring.domain.mapping.QFragranceTopNote; +import PerfumeOnMe.spring.web.dto.fragrance.FragranceRequestDTO; import lombok.RequiredArgsConstructor; @Repository @@ -24,6 +37,7 @@ public class FragranceRepositoryImpl implements FragranceRepositoryCustom { private final JPAQueryFactory queryFactory; + // 향수 상세 @Override public Optional findByIdWithAllDetails(Long id) { QFragrance f = QFragrance.fragrance; @@ -39,6 +53,7 @@ public Optional findByIdWithAllDetails(Long id) { return Optional.ofNullable(result); } + //향수 검색 @Override public Page findBySearchKeyword(String keyword, Pageable pageable) { QFragrance fragrance = QFragrance.fragrance; @@ -66,4 +81,79 @@ private BooleanExpression containsKeyword(StringPath field, String keyword) { return keyword == null ? null : field.containsIgnoreCase(keyword); } + // 향수 필터링 + @Override + public Page findByFilter(FragranceRequestDTO.FragranceFilterRequest r, Pageable pageable) { + QFragrance f = QFragrance.fragrance; + QFragrancePrice fp = QFragrancePrice.fragrancePrice; + QPrice price = QPrice.price1; + QFragranceLocation fl = QFragranceLocation.fragranceLocation; + QLocation l = QLocation.location; + QFragranceSeason fs = QFragranceSeason.fragranceSeason; + QSeason s = QSeason.season; + QFragranceTopNote ftn = QFragranceTopNote.fragranceTopNote; + QFragranceMiddleNote fmn = QFragranceMiddleNote.fragranceMiddleNote; + QFragranceBaseNote fbn = QFragranceBaseNote.fragranceBaseNote; + QNote topNote = new QNote("topNote"); + QNote middleNote = new QNote("middleNote"); + QNote baseNote = new QNote("baseNote"); + + BooleanBuilder whereClause = new BooleanBuilder(); + + if (r.getFragranceType() != null) { + whereClause.and(f.fragranceType.eq(FragranceType.valueOf(r.getFragranceType()))); + } + if (r.getGender() != null) { + whereClause.and(f.gender.eq(FragranceGender.valueOf(r.getGender()))); + } + if (r.getSituationId() != null) { + whereClause.and(l.id.eq(r.getSituationId())); + } + if (r.getSeasonId() != null) { + whereClause.and(s.id.eq(r.getSeasonId())); + } + if (r.getNoteCategoryId() != null) { + BooleanBuilder noteBuilder = new BooleanBuilder(); + noteBuilder.or(topNote.top.isTrue().and(topNote.id.eq(r.getNoteCategoryId()))); + noteBuilder.or(middleNote.middle.isTrue().and(middleNote.id.eq(r.getNoteCategoryId()))); + noteBuilder.or(baseNote.base.isTrue().and(baseNote.id.eq(r.getNoteCategoryId()))); + whereClause.and(noteBuilder); + } + if (r.getPriceMin() != null && r.getPriceMax() != null) { + whereClause.and(price.price.between(r.getPriceMin(), r.getPriceMax())); + } else if (r.getPriceMin() != null) { + whereClause.and(price.price.goe(r.getPriceMin())); + } else if (r.getPriceMax() != null) { + whereClause.and(price.price.loe(r.getPriceMax())); + } + + List result = queryFactory.selectFrom(f) + .distinct() + .leftJoin(f.fragrancePriceList, fp).fetchJoin() + .leftJoin(fp.price, price) + .leftJoin(f.fragranceLocationList, fl).leftJoin(fl.location, l) + .leftJoin(f.fragranceSeasonList, fs).leftJoin(fs.season, s) + .leftJoin(f.fragranceTopNoteList, ftn).leftJoin(ftn.note, topNote) + .leftJoin(f.fragranceMiddleNoteList, fmn).leftJoin(fmn.note, middleNote) + .leftJoin(f.fragranceBaseNoteList, fbn).leftJoin(fbn.note, baseNote) + .where(whereClause) + .orderBy(f.name.asc()) // "가,나,다 순으로 정렬" + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory.select(f.countDistinct()) + .from(f) + .leftJoin(f.fragrancePriceList, fp) + .leftJoin(fp.price, price) + .leftJoin(f.fragranceLocationList, fl).leftJoin(fl.location, l) + .leftJoin(f.fragranceSeasonList, fs).leftJoin(fs.season, s) + .leftJoin(f.fragranceTopNoteList, ftn).leftJoin(ftn.note, topNote) + .leftJoin(f.fragranceMiddleNoteList, fmn).leftJoin(fmn.note, middleNote) + .leftJoin(f.fragranceBaseNoteList, fbn).leftJoin(fbn.note, baseNote) + .where(whereClause) + .fetchOne(); + + return new PageImpl<>(result, pageable, total != null ? total : 0); + } } diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java index c5e0bc0..d5a5064 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java @@ -2,6 +2,7 @@ import java.util.Map; +import PerfumeOnMe.spring.web.dto.fragrance.FragranceRequestDTO; import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; public interface FragranceService { @@ -10,5 +11,9 @@ public interface FragranceService { // 향수 검색 API Map searchFragrances(String keyword, int page, int size); + + // 향수 필터링 API + FragranceResponseDTO.FragranceSearchFinalResult searchFragrancesByFilter( + FragranceRequestDTO.FragranceFilterRequest request); } diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java index fa646ad..018ea7c 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java @@ -3,9 +3,12 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -13,7 +16,13 @@ import PerfumeOnMe.spring.apiPayload.exception.GeneralException; import PerfumeOnMe.spring.converter.FragranceConverter; import PerfumeOnMe.spring.domain.Fragrance; +import PerfumeOnMe.spring.domain.enums.FragranceGender; +import PerfumeOnMe.spring.domain.enums.FragranceType; import PerfumeOnMe.spring.repository.fragrance.FragranceRepository; +import PerfumeOnMe.spring.repository.location.LocationRepository; +import PerfumeOnMe.spring.repository.note.NoteRepository; +import PerfumeOnMe.spring.repository.season.SeasonRepository; +import PerfumeOnMe.spring.web.dto.fragrance.FragranceRequestDTO; import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; import lombok.RequiredArgsConstructor; @@ -23,6 +32,9 @@ public class FragranceServiceImpl implements FragranceService { private final FragranceRepository fragranceRepository; + private final NoteRepository noteRepository; + private final SeasonRepository seasonRepository; + private final LocationRepository locationRepository; // 향수 상세 API @Override @@ -42,10 +54,62 @@ public Map searchFragrances(String keyword, int page, int size) fragrancePage.getContent()); Map result = new HashMap<>(); - result.put("fragranceList", dtoList); + result.put("content", dtoList); result.put("hasNext", fragrancePage.hasNext()); return result; } + // 향수 필터링 API + @Override + public FragranceResponseDTO.FragranceSearchFinalResult searchFragrancesByFilter( + FragranceRequestDTO.FragranceFilterRequest request) { + + // Enum 유효성 검사 + if (request.getGender() != null) { + try { + FragranceGender.valueOf(request.getGender()); + } catch (IllegalArgumentException e) { + throw new GeneralException(ErrorStatus.INVALID_GENDER); + } + } + + if (request.getFragranceType() != null) { + try { + FragranceType.valueOf(request.getFragranceType()); + } catch (IllegalArgumentException e) { + throw new GeneralException(ErrorStatus.INVALID_FRAGRANCE_TYPE); + } + } + + // 가격 범위 유효성 검사 + if (request.getPriceMin() != null && request.getPriceMax() != null + && request.getPriceMin() > request.getPriceMax()) { + throw new GeneralException(ErrorStatus.INVALID_PRICE_RANGE); + } + + // ID 존재 유효성 검사 (note, season, situation) + if (request.getNoteCategoryId() != null && !noteRepository.existsById(request.getNoteCategoryId())) { + throw new GeneralException(ErrorStatus.INVALID_NOTE_ID); + } + if (request.getSeasonId() != null && !seasonRepository.existsById(request.getSeasonId())) { + throw new GeneralException(ErrorStatus.INVALID_SEASON_ID); + } + if (request.getSituationId() != null && !locationRepository.existsById(request.getSituationId())) { + throw new GeneralException(ErrorStatus.INVALID_SITUATION_ID); + } + + Pageable pageable = PageRequest.of(request.getPage(), request.getSize(), Sort.by("name")); + Page fragrancePage = fragranceRepository.findByFilter(request, pageable); + + List content = fragrancePage.getContent().stream() + .map(FragranceConverter::toSearchResultDto) + .collect(Collectors.toList()); + + return FragranceResponseDTO.FragranceSearchFinalResult.builder() + .content(content) + .hasNext(fragrancePage.hasNext()) + .build(); + } + } diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java index abc08a8..9bc6e1a 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java @@ -30,7 +30,9 @@ public class FragranceController { private final FragranceService fragranceService; - // 향수 상세 API + /** + * 향수 상세 API + */ @GetMapping("/{fragranceId}") @Operation( summary = "향수 상세 조회", @@ -49,7 +51,9 @@ public ResponseEntity> g return ResponseEntity.ok(ApiResponse.onSuccess(result)); } - // 향수 검색 API + /** + * 향수 검색 API + */ @GetMapping("/search") @Operation( summary = "향수 키워드 검색 (무한 스크롤)", @@ -72,4 +76,34 @@ public ResponseEntity>> searchFragrances( request.getSize()); return ResponseEntity.ok(ApiResponse.onSuccess(result)); } + + /** + * 향수 필터링 API + */ + @GetMapping("/filter") + @Operation( + summary = "향수 필터링 검색 (무한 스크롤)", + description = "필터링을 통해 걸러진 향수 목록을, 페이징 처리된 결과로 반환합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "요청에 성공하였습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = FragranceResponseDTO.FragranceSearchResult.class))), + } + ) + @Parameters({ + @Parameter(name = "noteCategoryId", description = "향수 카테고리(노트) ID"), + @Parameter(name = "fragranceType", description = "향수 타입 필터"), + @Parameter(name = "gender", description = "성별 필터"), + @Parameter(name = "situationId", description = "사용하는 상황 필터(Location ID)"), + @Parameter(name = "seasonId", description = "계절 필터(계절 ID)"), + @Parameter(name = "priceMin", description = "최소 가격"), + @Parameter(name = "priceMax", description = "최대 가격"), + @Parameter(name = "page", description = "페이지 번호 (0부터 시작)"), + @Parameter(name = "size", description = "한 페이지에 불러올 향수 개수") + }) + public ResponseEntity> searchFragrancesByFilter( + @Valid @ModelAttribute FragranceRequestDTO.FragranceFilterRequest request + ) { + FragranceResponseDTO.FragranceSearchFinalResult result = fragranceService.searchFragrancesByFilter(request); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } + } From 08386fd03286c921b72ebb1dcf985cf303b55c7a Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Thu, 10 Jul 2025 18:45:05 +0900 Subject: [PATCH 119/339] =?UTF-8?q?[Fix]=20Q=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EA=B0=9D=EC=B2=B4=EB=93=A4=EC=9D=84=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EB=A0=88=EB=B2=A8=EC=97=90=20=EC=84=A0=EC=96=B8=20?= =?UTF-8?q?(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fragrance/FragranceRepositoryImpl.java | 43 +++++++++---------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java index 16afcbb..4971400 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java @@ -37,13 +37,24 @@ public class FragranceRepositoryImpl implements FragranceRepositoryCustom { private final JPAQueryFactory queryFactory; + // Q 도메인 객체를 클래스 레벨에서 선언 + private final QFragrance f = QFragrance.fragrance; + private final QFragrancePrice fp = QFragrancePrice.fragrancePrice; + private final QPrice price = QPrice.price1; + private final QFragranceLocation fl = QFragranceLocation.fragranceLocation; + private final QLocation l = QLocation.location; + private final QFragranceSeason fs = QFragranceSeason.fragranceSeason; + private final QSeason s = QSeason.season; + private final QFragranceTopNote ftn = QFragranceTopNote.fragranceTopNote; + private final QFragranceMiddleNote fmn = QFragranceMiddleNote.fragranceMiddleNote; + private final QFragranceBaseNote fbn = QFragranceBaseNote.fragranceBaseNote; + private final QNote topNote = new QNote("topNote"); + private final QNote middleNote = new QNote("middleNote"); + private final QNote baseNote = new QNote("baseNote"); + // 향수 상세 @Override public Optional findByIdWithAllDetails(Long id) { - QFragrance f = QFragrance.fragrance; - QFragrancePrice fp = QFragrancePrice.fragrancePrice; - QPrice price = QPrice.price1; - Fragrance result = queryFactory.selectFrom(f) .leftJoin(f.fragrancePriceList, fp).fetchJoin() .leftJoin(fp.price, price).fetchJoin() @@ -56,21 +67,20 @@ public Optional findByIdWithAllDetails(Long id) { //향수 검색 @Override public Page findBySearchKeyword(String keyword, Pageable pageable) { - QFragrance fragrance = QFragrance.fragrance; // 실제 결과 데이터 조회 List content = queryFactory - .selectFrom(fragrance) - .where(containsKeyword(fragrance.name, keyword)) + .selectFrom(f) + .where(containsKeyword(f.name, keyword)) .offset(pageable.getOffset()) // 몇 번째부터 가져올지 (page * size) .limit(pageable.getPageSize()) // 몇 개 가져올지 .fetch(); // 카운트 쿼리 -> 총 조회된 향수가 몇 개인지 Long count = queryFactory - .select(fragrance.count()) - .from(fragrance) - .where(containsKeyword(fragrance.name, keyword)) + .select(f.count()) + .from(f) + .where(containsKeyword(f.name, keyword)) .fetchOne(); // Page 객체 생성 @@ -84,19 +94,6 @@ private BooleanExpression containsKeyword(StringPath field, String keyword) { // 향수 필터링 @Override public Page findByFilter(FragranceRequestDTO.FragranceFilterRequest r, Pageable pageable) { - QFragrance f = QFragrance.fragrance; - QFragrancePrice fp = QFragrancePrice.fragrancePrice; - QPrice price = QPrice.price1; - QFragranceLocation fl = QFragranceLocation.fragranceLocation; - QLocation l = QLocation.location; - QFragranceSeason fs = QFragranceSeason.fragranceSeason; - QSeason s = QSeason.season; - QFragranceTopNote ftn = QFragranceTopNote.fragranceTopNote; - QFragranceMiddleNote fmn = QFragranceMiddleNote.fragranceMiddleNote; - QFragranceBaseNote fbn = QFragranceBaseNote.fragranceBaseNote; - QNote topNote = new QNote("topNote"); - QNote middleNote = new QNote("middleNote"); - QNote baseNote = new QNote("baseNote"); BooleanBuilder whereClause = new BooleanBuilder(); From 58cbef6b1c0408d2cd129fc3eba46197d91ae71d Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Thu, 10 Jul 2025 18:49:05 +0900 Subject: [PATCH 120/339] =?UTF-8?q?[Fix]=20Swagger=20API=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5(=EC=97=90=EB=9F=AC=20=EC=9D=91=EB=8B=B5=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C)=20=EB=AA=85=EC=84=B8=20=EC=A0=95=EC=9D=98=20(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/web/controller/FragranceController.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java index 9bc6e1a..195edde 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java @@ -86,6 +86,12 @@ public ResponseEntity>> searchFragrances( description = "필터링을 통해 걸러진 향수 목록을, 페이징 처리된 결과로 반환합니다.", responses = { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "요청에 성공하였습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = FragranceResponseDTO.FragranceSearchResult.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FILTER4001", description = "유효하지 않은 성별입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FILTER4002", description = "유효하지 않은 향수 타입입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FILTER4003", description = "유효하지 않은 노트 ID 입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FILTER4004", description = "유효하지 않은 계절 ID 입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FILTER4005", description = "유효하지 않은 장소 ID 입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FILTER4006", description = "가격 범위가 올바르지 않습니다.") } ) @Parameters({ From 0030e269cd3562258a924a0d05a48220b40b3c63 Mon Sep 17 00:00:00 2001 From: bulee5328 Date: Thu, 10 Jul 2025 19:19:00 +0900 Subject: [PATCH 121/339] [Feature] add-favorites-api (#21) --- .../apiPayload/code/status/ErrorStatus.java | 3 ++ .../spring/converter/FragranceConverter.java | 8 ++++++ .../fragrance/FragranceRepository.java | 2 ++ .../repository/user/UserRepository.java | 2 ++ .../UserFragranceRepository.java | 11 ++++++++ .../service/fragrance/FragranceService.java | 3 ++ .../fragrance/FragranceServiceImpl.java | 28 +++++++++++++++++++ .../web/controller/FragranceController.java | 25 +++++++++++++++++ .../dto/fragrance/FragranceResponseDTO.java | 9 ++++++ 9 files changed, 91 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepository.java diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index 980a3ce..3d1c837 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -42,6 +42,9 @@ public enum ErrorStatus implements BaseErrorCode { // 향수 상세 페이지 에러 FRAGRANCE_NOT_FOUND(HttpStatus.BAD_REQUEST, "FRAGRANCE4001", "해당 ID에 해당하는 향수를 찾을 수 없습니다."), + // 향수 즐겨찾기 에러 + ALREADY_FAVORITES_ERROR(HttpStatus.BAD_REQUEST, "FAVORITES4001", "이미 즐겨찾기에 등록한 향수입니다."), + // 예시,,, ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); diff --git a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java index 2abaf8d..1d58ec7 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java @@ -15,6 +15,7 @@ import PerfumeOnMe.spring.domain.mapping.FragrancePrice; import PerfumeOnMe.spring.domain.mapping.FragranceSeason; import PerfumeOnMe.spring.domain.mapping.FragranceTopNote; +import PerfumeOnMe.spring.domain.mapping.UserFragrance; import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; public class FragranceConverter { @@ -135,4 +136,11 @@ public static List toSearchResultDto .collect(Collectors.toList()); } + // 향수 즐겨찾기 등록 API + public static FragranceResponseDTO.FavoriteResponseDTO toFavoriteResponseDTO(UserFragrance userFragrance) { + return FragranceResponseDTO.FavoriteResponseDTO.builder() + .fragranceId(userFragrance.getFragrance().getId()) + .build(); + } + } diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepository.java b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepository.java index 50d4529..f45ff84 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepository.java @@ -8,4 +8,6 @@ public interface FragranceRepository extends JpaRepository, FragranceRepositoryCustom { Optional findByName(String name); + + Optional findById(Long id); } diff --git a/src/main/java/PerfumeOnMe/spring/repository/user/UserRepository.java b/src/main/java/PerfumeOnMe/spring/repository/user/UserRepository.java index ef38f85..c3beb0f 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/user/UserRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/user/UserRepository.java @@ -9,4 +9,6 @@ public interface UserRepository extends JpaRepository, UserRepositoryCustom { Optional findUserByLoginId(String loginId); + + Optional findById(Long id); } diff --git a/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepository.java b/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepository.java new file mode 100644 index 0000000..cf0fd3f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepository.java @@ -0,0 +1,11 @@ +package PerfumeOnMe.spring.repository.userFragrance; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.domain.Fragrance; +import PerfumeOnMe.spring.domain.User; +import PerfumeOnMe.spring.domain.mapping.UserFragrance; + +public interface UserFragranceRepository extends JpaRepository { + boolean existsByUserAndFragrance(User user, Fragrance fragrance); +} diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java index c5e0bc0..2d6b2f3 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java @@ -10,5 +10,8 @@ public interface FragranceService { // 향수 검색 API Map searchFragrances(String keyword, int page, int size); + + // 향수 즐겨찾기 등록 API + FragranceResponseDTO.FavoriteResponseDTO addFavorite(Long userId, Long fragranceId); } diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java index fa646ad..3d2f8d7 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java @@ -13,7 +13,11 @@ import PerfumeOnMe.spring.apiPayload.exception.GeneralException; import PerfumeOnMe.spring.converter.FragranceConverter; import PerfumeOnMe.spring.domain.Fragrance; +import PerfumeOnMe.spring.domain.User; +import PerfumeOnMe.spring.domain.mapping.UserFragrance; import PerfumeOnMe.spring.repository.fragrance.FragranceRepository; +import PerfumeOnMe.spring.repository.user.UserRepository; +import PerfumeOnMe.spring.repository.userFragrance.UserFragranceRepository; import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; import lombok.RequiredArgsConstructor; @@ -23,6 +27,8 @@ public class FragranceServiceImpl implements FragranceService { private final FragranceRepository fragranceRepository; + private final UserRepository userRepository; + private final UserFragranceRepository userFragranceRepository; // 향수 상세 API @Override @@ -48,4 +54,26 @@ public Map searchFragrances(String keyword, int page, int size) return result; } + // 향수 즐겨찾기 등록 API + @Override + @Transactional(readOnly = false) + public FragranceResponseDTO.FavoriteResponseDTO addFavorite(Long userId, Long fragranceId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + Fragrance fragrance = fragranceRepository.findById(fragranceId) + .orElseThrow(() -> new GeneralException(ErrorStatus.FRAGRANCE_NOT_FOUND)); + + if (userFragranceRepository.existsByUserAndFragrance(user, fragrance)) { + throw new GeneralException(ErrorStatus.ALREADY_FAVORITES_ERROR); + } + + UserFragrance favorite = UserFragrance.builder() + .user(user) + .fragrance(fragrance) + .build(); + + userFragranceRepository.save(favorite); + return FragranceConverter.toFavoriteResponseDTO(favorite); + } + } diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java index abc08a8..9a6a0be 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java @@ -3,13 +3,16 @@ import java.util.Map; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; import PerfumeOnMe.spring.service.fragrance.FragranceService; import PerfumeOnMe.spring.web.dto.fragrance.FragranceRequestDTO; import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; @@ -72,4 +75,26 @@ public ResponseEntity>> searchFragrances( request.getSize()); return ResponseEntity.ok(ApiResponse.onSuccess(result)); } + + // 향수 즐겨찾기 등록 API + @PostMapping("/{fragranceId}/favorites") + @Operation( + summary = "향수 즐겨찾기 등록", + description = "향수 ID로 향수 즐겨찾기를 등록하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "향수를 즐겨찾기에 등록했습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = FragranceResponseDTO.FavoriteResponseDTO.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FAVORITES4001", description = "이미 즐겨찾기에 등록한 향수입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FRAGRANCE4001", description = "해당 ID에 해당하는 향수를 찾을 수 없습니다.") + } + ) + @Parameters({ + @Parameter(name = "fragranceId", description = "향수 ID"), + }) + public ResponseEntity> addFavorite( + @PathVariable("fragranceId") Long fragranceId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + FragranceResponseDTO.FavoriteResponseDTO result = fragranceService.addFavorite(userDetails.getUserId(), + fragranceId); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java index 65cf635..ff6ae61 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java @@ -81,6 +81,15 @@ public static class FragranceSearchResult { private String imageUrl; } + // 향수 즐겨찾기 등록 응답 DTO + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class FavoriteResponseDTO { + private Long fragranceId; + } + } From b8cf4cc7642ffb45755f02c11306b9eae17f637d Mon Sep 17 00:00:00 2001 From: bulee5328 Date: Thu, 10 Jul 2025 20:27:22 +0900 Subject: [PATCH 122/339] [Feature] delete-favorites-api (#30) --- .../apiPayload/code/status/ErrorStatus.java | 3 ++- .../spring/converter/FragranceConverter.java | 8 ++++++ .../UserFragranceRepository.java | 4 +++ .../service/fragrance/FragranceService.java | 3 +++ .../fragrance/FragranceServiceImpl.java | 26 +++++++++++++++---- .../web/controller/FragranceController.java | 23 ++++++++++++++++ .../dto/fragrance/FragranceResponseDTO.java | 17 +++++++++--- 7 files changed, 74 insertions(+), 10 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index 7ef7edf..14f91de 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -44,7 +44,8 @@ public enum ErrorStatus implements BaseErrorCode { // 향수 즐겨찾기 에러 ALREADY_FAVORITES_ERROR(HttpStatus.BAD_REQUEST, "FAVORITES4001", "이미 즐겨찾기에 등록한 향수입니다."), - + FAVORITE_NOT_FOUND(HttpStatus.BAD_REQUEST, "FAVORITES4002", "즐겨찾기 목록에 존재하지 않는 향수입니다."), + // 향수 필터링 에러 INVALID_GENDER(HttpStatus.BAD_REQUEST, "FILTER4001", "유효하지 않은 성별입니다."), INVALID_FRAGRANCE_TYPE(HttpStatus.BAD_REQUEST, "FILTER4002", "유효하지 않은 향수 타입입니다."), diff --git a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java index 56db0cc..618f661 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java @@ -145,4 +145,12 @@ public static FragranceResponseDTO.FavoriteResponseDTO toFavoriteResponseDTO(Use .build(); } + // 향수 즐겨찾기 취소 API + public static FragranceResponseDTO.FavoriteCancelResponseDTO toFavoriteCancelResponseDTO( + UserFragrance userFragrance) { + return FragranceResponseDTO.FavoriteCancelResponseDTO.builder() + .fragranceId(userFragrance.getFragrance().getId()) + .build(); + } + } diff --git a/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepository.java b/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepository.java index cf0fd3f..3c511f5 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepository.java @@ -1,5 +1,7 @@ package PerfumeOnMe.spring.repository.userFragrance; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import PerfumeOnMe.spring.domain.Fragrance; @@ -8,4 +10,6 @@ public interface UserFragranceRepository extends JpaRepository { boolean existsByUserAndFragrance(User user, Fragrance fragrance); + + Optional findByUserAndFragrance(User user, Fragrance fragrance); } diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java index b64992d..dab2f4b 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java @@ -15,6 +15,9 @@ public interface FragranceService { // 향수 즐겨찾기 등록 API FragranceResponseDTO.FavoriteResponseDTO addFavorite(Long userId, Long fragranceId); + // 향수 즐겨찾기 취소 API + FragranceResponseDTO.FavoriteCancelResponseDTO deleteFavorite(Long userId, Long fragranceId); + // 향수 필터링 API FragranceResponseDTO.FragranceSearchFinalResult searchFragrancesByFilter( FragranceRequestDTO.FragranceFilterRequest request); diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java index cdc7ca8..ed9c110 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java @@ -17,15 +17,15 @@ import PerfumeOnMe.spring.converter.FragranceConverter; import PerfumeOnMe.spring.domain.Fragrance; import PerfumeOnMe.spring.domain.User; -import PerfumeOnMe.spring.domain.mapping.UserFragrance; -import PerfumeOnMe.spring.repository.user.UserRepository; -import PerfumeOnMe.spring.repository.userFragrance.UserFragranceRepository; import PerfumeOnMe.spring.domain.enums.FragranceGender; import PerfumeOnMe.spring.domain.enums.FragranceType; +import PerfumeOnMe.spring.domain.mapping.UserFragrance; import PerfumeOnMe.spring.repository.fragrance.FragranceRepository; import PerfumeOnMe.spring.repository.location.LocationRepository; import PerfumeOnMe.spring.repository.note.NoteRepository; import PerfumeOnMe.spring.repository.season.SeasonRepository; +import PerfumeOnMe.spring.repository.user.UserRepository; +import PerfumeOnMe.spring.repository.userFragrance.UserFragranceRepository; import PerfumeOnMe.spring.web.dto.fragrance.FragranceRequestDTO; import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; import lombok.RequiredArgsConstructor; @@ -86,8 +86,24 @@ public FragranceResponseDTO.FavoriteResponseDTO addFavorite(Long userId, Long fr userFragranceRepository.save(favorite); return FragranceConverter.toFavoriteResponseDTO(favorite); - } - + } + + // 향수 즐겨찾기 취소 API + @Override + @Transactional(readOnly = false) + public FragranceResponseDTO.FavoriteCancelResponseDTO deleteFavorite(Long userId, Long fragranceId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + Fragrance fragrance = fragranceRepository.findById(fragranceId) + .orElseThrow(() -> new GeneralException(ErrorStatus.FRAGRANCE_NOT_FOUND)); + + UserFragrance favorite = userFragranceRepository.findByUserAndFragrance(user, fragrance) + .orElseThrow(() -> new GeneralException(ErrorStatus.FAVORITE_NOT_FOUND)); + + userFragranceRepository.delete(favorite); + return FragranceConverter.toFavoriteCancelResponseDTO(favorite); + } + // 향수 필터링 API @Override public FragranceResponseDTO.FragranceSearchFinalResult searchFragrancesByFilter( diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java index c24357c..06f240b 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java @@ -4,6 +4,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; @@ -102,6 +103,28 @@ public ResponseEntity> add return ResponseEntity.ok(ApiResponse.onSuccess(result)); } + // 향수 즐겨찾기 취소 API + @DeleteMapping("/{fragranceId}/favorites") + @Operation( + summary = "향수 즐겨찾기 취소", + description = "향수 ID로 향수 즐겨찾기를 취소하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "즐겨찾기에서 향수를 제거했습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = FragranceResponseDTO.FavoriteCancelResponseDTO.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FAVORITES4002", description = "즐겨찾기 목록에 존재하지 않는 향수입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FRAGRANCE4001", description = "해당 ID에 해당하는 향수를 찾을 수 없습니다.") + } + ) + @Parameters({ + @Parameter(name = "fragranceId", description = "향수 ID"), + }) + public ResponseEntity> deleteFavorite( + @PathVariable("fragranceId") Long fragranceId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + FragranceResponseDTO.FavoriteCancelResponseDTO result = fragranceService.deleteFavorite(userDetails.getUserId(), + fragranceId); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } + /** * 향수 필터링 API */ diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java index 872bc4f..116acea 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java @@ -82,14 +82,23 @@ public static class FragranceSearchResult { } // 향수 즐겨찾기 등록 응답 DTO - @Getter + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class FavoriteResponseDTO { + private Long fragranceId; + } + + // 향수 즐겨찾기 등록 취소 DTO + @Getter @Builder @AllArgsConstructor @NoArgsConstructor - public static class FavoriteResponseDTO { + public static class FavoriteCancelResponseDTO { private Long fragranceId; } - + // 향수, 검색 필터링 응답 DTO (최종 응답) @Getter @Builder @@ -99,7 +108,7 @@ public static class FragranceSearchFinalResult { private List content; private boolean hasNext; } - + } From f83af041ce3079c043a6172fa7afc1e31d1fd012 Mon Sep 17 00:00:00 2001 From: bulee5328 Date: Thu, 10 Jul 2025 20:39:33 +0900 Subject: [PATCH 123/339] =?UTF-8?q?[Feature]=20UserFragranceRepositoryCust?= =?UTF-8?q?om=20=EC=B6=94=EA=B0=80=20(#30)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/userFragrance/UserFragranceRepository.java | 2 +- .../userFragrance/UserFragranceRepositoryCustom.java | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepositoryCustom.java diff --git a/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepository.java b/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepository.java index 3c511f5..717d978 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepository.java @@ -8,7 +8,7 @@ import PerfumeOnMe.spring.domain.User; import PerfumeOnMe.spring.domain.mapping.UserFragrance; -public interface UserFragranceRepository extends JpaRepository { +public interface UserFragranceRepository extends JpaRepository, UserFragranceRepositoryCustom { boolean existsByUserAndFragrance(User user, Fragrance fragrance); Optional findByUserAndFragrance(User user, Fragrance fragrance); diff --git a/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepositoryCustom.java new file mode 100644 index 0000000..e47f1dc --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepositoryCustom.java @@ -0,0 +1,5 @@ +package PerfumeOnMe.spring.repository.userFragrance; + +public interface UserFragranceRepositoryCustom { + +} From f08e8b031277fb76725365144e35c2de97832dd5 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 11 Jul 2025 01:41:27 +0900 Subject: [PATCH 124/339] =?UTF-8?q?[Feature]=20=ED=96=A5=EC=88=98=20?= =?UTF-8?q?=EC=83=81=EC=84=B8,=20=EA=B2=80=EC=83=89,=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20=EC=9D=91=EB=8B=B5=20DTO=20=EC=97=90=20=EC=A6=90?= =?UTF-8?q?=EA=B2=A8=EC=B0=BE=EA=B8=B0=20=EA=B4=80=EB=A0=A8=20liked=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/web/dto/fragrance/FragranceResponseDTO.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java index 116acea..9dc9e12 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java @@ -27,6 +27,7 @@ public static class FragranceDetailResult { private List locations; private List seasons; private String homePageUrl; + private boolean liked; @Getter @Builder @@ -79,6 +80,7 @@ public static class FragranceSearchResult { private String name; private Integer minPrice; private String imageUrl; + private boolean liked; } // 향수 즐겨찾기 등록 응답 DTO From 54fa726293e10d9529461eb5b369d52d9359e31e Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 11 Jul 2025 01:42:16 +0900 Subject: [PATCH 125/339] =?UTF-8?q?[Fix]=20=ED=96=A5=EC=88=98=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20API=20service=20=EC=97=90=EC=84=9C=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=EA=B0=92=20Map=20->=20DTO=20=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(#33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/service/fragrance/FragranceService.java | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java index dab2f4b..3d8adc7 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java @@ -1,16 +1,14 @@ package PerfumeOnMe.spring.service.fragrance; -import java.util.Map; - import PerfumeOnMe.spring.web.dto.fragrance.FragranceRequestDTO; import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; public interface FragranceService { // 향수 상세 API - FragranceResponseDTO.FragranceDetailResult getFragranceDetail(Long fragranceId); + FragranceResponseDTO.FragranceDetailResult getFragranceDetail(Long fragranceId, Long userId); // 향수 검색 API - Map searchFragrances(String keyword, int page, int size); + FragranceResponseDTO.FragranceSearchFinalResult searchFragrances(String keyword, int page, int size, Long userId); // 향수 즐겨찾기 등록 API FragranceResponseDTO.FavoriteResponseDTO addFavorite(Long userId, Long fragranceId); @@ -20,6 +18,6 @@ public interface FragranceService { // 향수 필터링 API FragranceResponseDTO.FragranceSearchFinalResult searchFragrancesByFilter( - FragranceRequestDTO.FragranceFilterRequest request); + FragranceRequestDTO.FragranceFilterRequest request, Long userId); } From 17ea534f49b8eb0b6b52875e8310c33a023a4518 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 11 Jul 2025 01:44:52 +0900 Subject: [PATCH 126/339] =?UTF-8?q?[Feature]=20=EC=A6=90=EA=B2=A8=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=EC=95=88=EC=97=90=20=ED=96=A5=EC=88=98=EA=B0=80=20?= =?UTF-8?q?=EC=9E=88=EB=8A=94=EC=A7=80=EC=97=90=20=EB=8C=80=ED=95=9C=20?= =?UTF-8?q?=EC=9C=A0=EB=AC=B4=EB=A5=BC=20=ED=99=95=EC=9D=B8=ED=95=98?= =?UTF-8?q?=EB=8A=94=20Like=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20(#33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fragrance/FragranceServiceImpl.java | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java index ed9c110..edf5969 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java @@ -1,8 +1,6 @@ package PerfumeOnMe.spring.service.fragrance; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.stream.Collectors; import org.springframework.data.domain.Page; @@ -44,26 +42,35 @@ public class FragranceServiceImpl implements FragranceService { // 향수 상세 API @Override - public FragranceResponseDTO.FragranceDetailResult getFragranceDetail(Long fragranceId) { + public FragranceResponseDTO.FragranceDetailResult getFragranceDetail(Long fragranceId, Long userId) { Fragrance fragrance = fragranceRepository.findByIdWithAllDetails(fragranceId) .orElseThrow(() -> new GeneralException(ErrorStatus.FRAGRANCE_NOT_FOUND)); - return FragranceConverter.toDetailDto(fragrance); + + boolean liked = Like(userId, fragranceId); + + return FragranceConverter.toDetailDto(fragrance, liked); } // 향수 검색 API @Override - public Map searchFragrances(String keyword, int page, int size) { + public FragranceResponseDTO.FragranceSearchFinalResult searchFragrances(String keyword, int page, int size, + Long userId) { PageRequest pageable = PageRequest.of(page, size); Page fragrancePage = fragranceRepository.findBySearchKeyword(keyword, pageable); - List dtoList = FragranceConverter.toSearchResultDtoList( - fragrancePage.getContent()); + List content = fragrancePage.getContent().stream() + .map(fragrance -> { + // 즐겨찾기 확인 + boolean liked = Like(userId, fragrance.getId()); + return FragranceConverter.toSearchResultDto(fragrance, liked); + }) + .collect(Collectors.toList()); - Map result = new HashMap<>(); - result.put("content", dtoList); - result.put("hasNext", fragrancePage.hasNext()); + return FragranceResponseDTO.FragranceSearchFinalResult.builder() + .content(content) + .hasNext(fragrancePage.hasNext()) + .build(); - return result; } // 향수 즐겨찾기 등록 API @@ -107,7 +114,7 @@ public FragranceResponseDTO.FavoriteCancelResponseDTO deleteFavorite(Long userId // 향수 필터링 API @Override public FragranceResponseDTO.FragranceSearchFinalResult searchFragrancesByFilter( - FragranceRequestDTO.FragranceFilterRequest request) { + FragranceRequestDTO.FragranceFilterRequest request, Long userId) { // Enum 유효성 검사 if (request.getGender() != null) { @@ -147,7 +154,11 @@ public FragranceResponseDTO.FragranceSearchFinalResult searchFragrancesByFilter( Page fragrancePage = fragranceRepository.findByFilter(request, pageable); List content = fragrancePage.getContent().stream() - .map(FragranceConverter::toSearchResultDto) + .map(fragrance -> { + // 즐겨찾기 확인 + boolean liked = Like(userId, fragrance.getId()); + return FragranceConverter.toSearchResultDto(fragrance, liked); + }) .collect(Collectors.toList()); return FragranceResponseDTO.FragranceSearchFinalResult.builder() @@ -156,4 +167,9 @@ public FragranceResponseDTO.FragranceSearchFinalResult searchFragrancesByFilter( .build(); } + // 사용자 id 와 향수 id 를 받아와 즐겨찾기 테이블에 해댱 향수가 있는지 없는지 확인하는 메서드 + private boolean Like(Long userId, Long fragranceId) { + return userFragranceRepository.existsByUserIdAndFragranceId(userId, fragranceId); + } + } From f8cd8ef7e1c096526a5f35de5f9e993533d381b1 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 11 Jul 2025 01:45:40 +0900 Subject: [PATCH 127/339] =?UTF-8?q?[Fix]=20Dto=20=EB=B3=80=ED=99=98=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=EC=97=90=20liked=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20toSearchResultDtoList=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=82=AD=EC=A0=9C=20(#33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/converter/FragranceConverter.java | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java index 618f661..eea39cd 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java @@ -23,7 +23,7 @@ public class FragranceConverter { /** * 향수 상세 페이지 조회 API */ - public static FragranceResponseDTO.FragranceDetailResult toDetailDto(Fragrance fragrance) { + public static FragranceResponseDTO.FragranceDetailResult toDetailDto(Fragrance fragrance, boolean liked) { return FragranceResponseDTO.FragranceDetailResult.builder() .id(fragrance.getId()) .brand(fragrance.getBrand().getShowBrand()) @@ -56,6 +56,7 @@ public static FragranceResponseDTO.FragranceDetailResult toDetailDto(Fragrance f .map(Season::getName) .collect(Collectors.toList())) .homePageUrl(fragrance.getHomePageURL()) + .liked(liked) .build(); } @@ -115,7 +116,7 @@ private static FragranceResponseDTO.FragranceDetailResult.NoteDto.NoteSection to */ // 향수 반환 dto 변환 처리 - public static FragranceResponseDTO.FragranceSearchResult toSearchResultDto(Fragrance fragrance) { + public static FragranceResponseDTO.FragranceSearchResult toSearchResultDto(Fragrance fragrance, boolean liked) { Integer minPrice = fragrance.getFragrancePriceList().stream() .map(fp -> fp.getPrice().getPrice()) .min(Comparator.naturalOrder()) // 각 향수의 최저가 @@ -127,17 +128,10 @@ public static FragranceResponseDTO.FragranceSearchResult toSearchResultDto(Fragr .name(fragrance.getName()) .minPrice(minPrice) .imageUrl(fragrance.getImageURL()) + .liked(liked) .build(); } - // toSearchResultDto 로 얻은 향수들의 리스트 - public static List toSearchResultDtoList( - List fragranceList) { - return fragranceList.stream() - .map(FragranceConverter::toSearchResultDto) - .collect(Collectors.toList()); - } - // 향수 즐겨찾기 등록 API public static FragranceResponseDTO.FavoriteResponseDTO toFavoriteResponseDTO(UserFragrance userFragrance) { return FragranceResponseDTO.FavoriteResponseDTO.builder() From 4483205283225514b713be996563e85dea25f563 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 11 Jul 2025 01:46:10 +0900 Subject: [PATCH 128/339] =?UTF-8?q?[Feature]=20existsByUserIdAndFragranceI?= =?UTF-8?q?d=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=83=9D=EC=84=B1=20(#33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/userFragrance/UserFragranceRepository.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepository.java b/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepository.java index 717d978..2c1d8b2 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepository.java @@ -12,4 +12,7 @@ public interface UserFragranceRepository extends JpaRepository findByUserAndFragrance(User user, Fragrance fragrance); + + boolean existsByUserIdAndFragranceId(Long userId, Long fragranceId); + } From 19d8146e6316be648647cad6e1a1ea10e6c44461 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 11 Jul 2025 01:47:09 +0900 Subject: [PATCH 129/339] =?UTF-8?q?[Fix]=20UserId=20=EB=A5=BC=20=EB=B0=9B?= =?UTF-8?q?=EC=95=84=EC=98=A4=EB=8A=94=20@AuthenticationPrincipal=20Custom?= =?UTF-8?q?UserDetails=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80(#33)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/controller/FragranceController.java | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java index 06f240b..0b671c5 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java @@ -1,7 +1,5 @@ package PerfumeOnMe.spring.web.controller; -import java.util.Map; - import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -50,8 +48,10 @@ public class FragranceController { @Parameter(name = "fragranceId", description = "향수 ID"), }) public ResponseEntity> getFragranceDetail( - @PathVariable("fragranceId") Long fragranceId) { - FragranceResponseDTO.FragranceDetailResult result = fragranceService.getFragranceDetail(fragranceId); + @PathVariable("fragranceId") Long fragranceId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + FragranceResponseDTO.FragranceDetailResult result = fragranceService.getFragranceDetail(fragranceId, + userDetails.getUserId()); return ResponseEntity.ok(ApiResponse.onSuccess(result)); } @@ -72,13 +72,16 @@ public ResponseEntity> g @Parameter(name = "page", description = "페이지 번호"), @Parameter(name = "size", description = "한 페이지에 불러올 향수 개수") }) - public ResponseEntity>> searchFragrances( - @Valid @ModelAttribute FragranceRequestDTO.FragranceSearchRequest request + public ResponseEntity> searchFragrances( + @Valid @ModelAttribute FragranceRequestDTO.FragranceSearchRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails ) { - // result 안에 fragranceList 와 hasNext 를 키로 갖는 구조 - Map result = fragranceService.searchFragrances(request.getKeyword(), request.getPage(), - request.getSize()); + FragranceResponseDTO.FragranceSearchFinalResult result = fragranceService.searchFragrances( + request.getKeyword(), request.getPage(), + request.getSize(), userDetails.getUserId()); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } // 향수 즐겨찾기 등록 API @@ -154,9 +157,11 @@ public ResponseEntity> searchFragrancesByFilter( - @Valid @ModelAttribute FragranceRequestDTO.FragranceFilterRequest request + @Valid @ModelAttribute FragranceRequestDTO.FragranceFilterRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails ) { - FragranceResponseDTO.FragranceSearchFinalResult result = fragranceService.searchFragrancesByFilter(request); + FragranceResponseDTO.FragranceSearchFinalResult result = fragranceService.searchFragrancesByFilter(request, + userDetails.getUserId()); return ResponseEntity.ok(ApiResponse.onSuccess(result)); } From e52e4ccaaa821c4f289ddc96b9527d9c82c49bf8 Mon Sep 17 00:00:00 2001 From: bulee5328 Date: Fri, 11 Jul 2025 02:23:28 +0900 Subject: [PATCH 130/339] =?UTF-8?q?[Feature]=20=EB=8B=A4=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80=20api=20=EA=B5=AC=ED=98=84=20(#3?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/converter/DiaryConverter.java | 15 ++++++ .../PerfumeOnMe/spring/domain/Fragrance.java | 7 +-- .../spring/domain/mapping/Diary.java | 15 +++--- .../repository/diary/DiaryRepository.java | 12 +++++ .../diary/DiaryRepositoryCustom.java | 5 ++ .../repository/diary/DiaryRepositoryImpl.java | 11 +++++ .../spring/service/Diary/DiaryService.java | 10 ++++ .../service/Diary/DiaryServiceImpl.java | 41 +++++++++++++++++ .../web/controller/DiaryController.java | 46 +++++++++++++++++++ .../spring/web/dto/diary/DiaryRequestDTO.java | 19 ++++++++ .../web/dto/diary/DiaryResponseDTO.java | 22 +++++++++ 11 files changed, 190 insertions(+), 13 deletions(-) create mode 100644 src/main/java/PerfumeOnMe/spring/converter/DiaryConverter.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepository.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepositoryCustom.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepositoryImpl.java create mode 100644 src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java create mode 100644 src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryRequestDTO.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java diff --git a/src/main/java/PerfumeOnMe/spring/converter/DiaryConverter.java b/src/main/java/PerfumeOnMe/spring/converter/DiaryConverter.java new file mode 100644 index 0000000..0c4fb50 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/converter/DiaryConverter.java @@ -0,0 +1,15 @@ +package PerfumeOnMe.spring.converter; + +import PerfumeOnMe.spring.domain.mapping.Diary; +import PerfumeOnMe.spring.web.dto.diary.DiaryResponseDTO; + +public class DiaryConverter { + + // 다이어리 추가 API + public static DiaryResponseDTO.AddDiaryResponse addDiaryResponseDTO(Diary diary) { + return DiaryResponseDTO.AddDiaryResponse.builder() + .id(diary.getId()) + .date(diary.getDate()) + .build(); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java b/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java index dbd8c67..31ebddd 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java @@ -10,7 +10,6 @@ import PerfumeOnMe.spring.domain.enums.Brand; import PerfumeOnMe.spring.domain.enums.FragranceGender; import PerfumeOnMe.spring.domain.enums.FragranceType; -import PerfumeOnMe.spring.domain.mapping.Diary; import PerfumeOnMe.spring.domain.mapping.FragranceBaseNote; import PerfumeOnMe.spring.domain.mapping.FragranceLocation; import PerfumeOnMe.spring.domain.mapping.FragranceMiddleNote; @@ -101,10 +100,6 @@ public class Fragrance extends BaseEntity { @Builder.Default private List userFragranceList = new ArrayList<>(); - @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) - @Builder.Default - private List diaryList = new ArrayList<>(); - @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) @Builder.Default private List fragranceSeasonList = new ArrayList<>(); @@ -112,7 +107,7 @@ public class Fragrance extends BaseEntity { @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) @Builder.Default private List fragranceLocationList = new ArrayList<>(); - + @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) @Builder.Default private List fragrancePriceList = new ArrayList<>(); diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/Diary.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/Diary.java index a4a584e..2ef77a5 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/Diary.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/Diary.java @@ -5,7 +5,8 @@ import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import PerfumeOnMe.spring.domain.Fragrance; +import com.fasterxml.jackson.annotation.JsonFormat; + import PerfumeOnMe.spring.domain.User; import PerfumeOnMe.spring.domain.base.BaseEntity; import jakarta.persistence.Column; @@ -41,13 +42,13 @@ public class Diary extends BaseEntity { @JoinColumn(name = "user_id") private User user; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "fragrance_id") - private Fragrance fragrance; - - @Column(columnDefinition = "TEXT", nullable = false) - private String content; + @Column(nullable = false, length = 150) + private String fragranceName; @Column(nullable = false) + @JsonFormat(pattern = "yyyy-MM-dd") private LocalDate date; + + @Column(columnDefinition = "TEXT", nullable = false) + private String content; } diff --git a/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepository.java b/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepository.java new file mode 100644 index 0000000..2a18552 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepository.java @@ -0,0 +1,12 @@ +package PerfumeOnMe.spring.repository.diary; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.domain.mapping.Diary; + +public interface DiaryRepository extends JpaRepository, DiaryRepositoryCustom { + + Optional findById(Long id); +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepositoryCustom.java new file mode 100644 index 0000000..7bdfca2 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepositoryCustom.java @@ -0,0 +1,5 @@ +package PerfumeOnMe.spring.repository.diary; + +public interface DiaryRepositoryCustom { + +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepositoryImpl.java new file mode 100644 index 0000000..be36e8b --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepositoryImpl.java @@ -0,0 +1,11 @@ +package PerfumeOnMe.spring.repository.diary; + +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class DiaryRepositoryImpl implements DiaryRepositoryCustom { + +} diff --git a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java new file mode 100644 index 0000000..5dd4a51 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.service.Diary; + +import PerfumeOnMe.spring.web.dto.diary.DiaryRequestDTO; +import PerfumeOnMe.spring.web.dto.diary.DiaryResponseDTO; + +public interface DiaryService { + + // 다이어리 추가 API + DiaryResponseDTO.AddDiaryResponse addDiary(Long userId, DiaryRequestDTO.AddDiaryRequest addDiaryRequest); +} diff --git a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java new file mode 100644 index 0000000..6faec16 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java @@ -0,0 +1,41 @@ +package PerfumeOnMe.spring.service.Diary; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.converter.DiaryConverter; +import PerfumeOnMe.spring.domain.User; +import PerfumeOnMe.spring.domain.mapping.Diary; +import PerfumeOnMe.spring.repository.diary.DiaryRepository; +import PerfumeOnMe.spring.repository.user.UserRepository; +import PerfumeOnMe.spring.web.dto.diary.DiaryRequestDTO; +import PerfumeOnMe.spring.web.dto.diary.DiaryResponseDTO; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class DiaryServiceImpl implements DiaryService { + + private final DiaryRepository diaryRepository; + private final UserRepository userRepository; + + // 다이어리 추가 API + @Override + public DiaryResponseDTO.AddDiaryResponse addDiary(Long userId, DiaryRequestDTO.AddDiaryRequest addDiaryRequest) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + + Diary diary = Diary.builder() + .user(user) + .fragranceName(addDiaryRequest.getFragranceName()) + .content(addDiaryRequest.getContent()) + .date(addDiaryRequest.getDate()) + .build(); + + diaryRepository.save(diary); + return DiaryConverter.addDiaryResponseDTO(diary); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java b/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java new file mode 100644 index 0000000..193051f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java @@ -0,0 +1,46 @@ +package PerfumeOnMe.spring.web.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; +import PerfumeOnMe.spring.service.Diary.DiaryService; +import PerfumeOnMe.spring.web.dto.diary.DiaryRequestDTO; +import PerfumeOnMe.spring.web.dto.diary.DiaryResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/diary") +@Tag(name = "Diary", description = "다이어리 CRUD API") +public class DiaryController { + + private final DiaryService diaryService; + + // 다이어리 추가 API + @PostMapping("/write") + @Operation( + summary = "다이어리 추가", + description = "다이어리를 추가하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "다이어리가 저장되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = DiaryResponseDTO.AddDiaryResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON401", description = "액세스 토큰을 입력해 주세요.") + } + ) + public ResponseEntity> addDiary( + @RequestBody @Valid DiaryRequestDTO.AddDiaryRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + DiaryResponseDTO.AddDiaryResponse result = diaryService.addDiary(userDetails.getUserId(), request); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryRequestDTO.java new file mode 100644 index 0000000..8431045 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryRequestDTO.java @@ -0,0 +1,19 @@ +package PerfumeOnMe.spring.web.dto.diary; + +import java.time.LocalDate; + +import lombok.Getter; +import lombok.Setter; + +public class DiaryRequestDTO { + + // 다이어리 추가 요청 DTO + @Getter + @Setter + public static class AddDiaryRequest { + private String fragranceName; + private String content; + private LocalDate date; + } + +} diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java new file mode 100644 index 0000000..ccff2b2 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java @@ -0,0 +1,22 @@ +package PerfumeOnMe.spring.web.dto.diary; + +import java.time.LocalDate; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class DiaryResponseDTO { + + // 다이어리 추가 응답 DTO + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class AddDiaryResponse { + private Long id; + private LocalDate date; + } + +} From 685f3bcb674bac02a2b7a6e73a41ae2398d6efe8 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 11 Jul 2025 14:35:39 +0900 Subject: [PATCH 131/339] =?UTF-8?q?[Fix]=20=ED=96=A5=EC=88=98=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20API=20=EC=9D=91=EB=8B=B5=20DTO=20=EB=B0=8F=20Conver?= =?UTF-8?q?ter=20=EB=B3=80=EA=B2=BD=20(#36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/PerfumeOnMe/spring/converter/FragranceConverter.java | 1 + .../spring/web/dto/fragrance/FragranceResponseDTO.java | 1 + 2 files changed, 2 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java index eea39cd..42bbf63 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java @@ -56,6 +56,7 @@ public static FragranceResponseDTO.FragranceDetailResult toDetailDto(Fragrance f .map(Season::getName) .collect(Collectors.toList())) .homePageUrl(fragrance.getHomePageURL()) + .imageURL(fragrance.getImageURL()) .liked(liked) .build(); } diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java index 9dc9e12..5655e44 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java @@ -27,6 +27,7 @@ public static class FragranceDetailResult { private List locations; private List seasons; private String homePageUrl; + private String imageURL; private boolean liked; @Getter From da738d46214d142bf10f3252c33f164cd5f0caf9 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 11 Jul 2025 14:36:50 +0900 Subject: [PATCH 132/339] =?UTF-8?q?[Fix]=20ExceptionAdvice=20=EC=97=90?= =?UTF-8?q?=EC=84=9C=20Exception=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?message=20=EB=A5=BC=20=5FINTERNAL=5FSERVER=5FERROR=20=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20(#36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apiPayload/exception/ExceptionAdvice.java | 205 +++++++++--------- 1 file changed, 106 insertions(+), 99 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/exception/ExceptionAdvice.java b/src/main/java/PerfumeOnMe/spring/apiPayload/exception/ExceptionAdvice.java index 0ee7159..e0495ec 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/exception/ExceptionAdvice.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/exception/ExceptionAdvice.java @@ -1,11 +1,9 @@ package PerfumeOnMe.spring.apiPayload.exception; -import PerfumeOnMe.spring.apiPayload.ApiResponse; -import PerfumeOnMe.spring.apiPayload.code.ErrorReasonDTO; -import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.validation.ConstraintViolationException; -import lombok.extern.slf4j.Slf4j; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; + import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatusCode; @@ -18,102 +16,111 @@ import org.springframework.web.context.request.WebRequest; import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Optional; +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.apiPayload.code.ErrorReasonDTO; +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; @Slf4j @RestControllerAdvice(annotations = {RestController.class}) public class ExceptionAdvice extends ResponseEntityExceptionHandler { - - @ExceptionHandler - public ResponseEntity validation(ConstraintViolationException e, WebRequest request) { - String errorMessage = e.getConstraintViolations().stream() - .map(constraintViolation -> constraintViolation.getMessage()) - .findFirst() - .orElseThrow(() -> new RuntimeException("ConstraintViolationException 추출 도중 에러 발생")); - - return handleExceptionInternalConstraint(e, ErrorStatus.valueOf(errorMessage), HttpHeaders.EMPTY,request); - } - - @Override - public ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException e, HttpHeaders headers, HttpStatusCode status, WebRequest request) { - - Map errors = new LinkedHashMap<>(); - - e.getBindingResult().getFieldErrors().stream() - .forEach(fieldError -> { - String fieldName = fieldError.getField(); - String errorMessage = Optional.ofNullable(fieldError.getDefaultMessage()).orElse(""); - errors.merge(fieldName, errorMessage, (existingErrorMessage, newErrorMessage) -> existingErrorMessage + ", " + newErrorMessage); - }); - - return handleExceptionInternalArgs(e,HttpHeaders.EMPTY,ErrorStatus.valueOf("_BAD_REQUEST"),request,errors); - } - - @ExceptionHandler - public ResponseEntity exception(Exception e, WebRequest request) { - e.printStackTrace(); - - return handleExceptionInternalFalse(e, ErrorStatus.ARTICLE_NOT_FOUND, HttpHeaders.EMPTY, ErrorStatus.ARTICLE_NOT_FOUND.getHttpStatus(),request, e.getMessage()); - } - - @ExceptionHandler(value = GeneralException.class) - public ResponseEntity onThrowException(GeneralException generalException, HttpServletRequest request) { - ErrorReasonDTO errorReasonHttpStatus = generalException.getErrorReasonHttpStatus(); - return handleExceptionInternal(generalException,errorReasonHttpStatus,null,request); - } - - private ResponseEntity handleExceptionInternal(Exception e, ErrorReasonDTO reason, - HttpHeaders headers, HttpServletRequest request) { - - ApiResponse body = ApiResponse.onFailure(reason.getCode(),reason.getMessage(),null); -// e.printStackTrace(); - - WebRequest webRequest = new ServletWebRequest(request); - return super.handleExceptionInternal( - e, - body, - headers, - reason.getHttpStatus(), - webRequest - ); - } - - private ResponseEntity handleExceptionInternalFalse(Exception e, ErrorStatus errorCommonStatus, - HttpHeaders headers, HttpStatus status, WebRequest request, String errorPoint) { - ApiResponse body = ApiResponse.onFailure(errorCommonStatus.getCode(),errorCommonStatus.getMessage(),errorPoint); - return super.handleExceptionInternal( - e, - body, - headers, - status, - request - ); - } - - private ResponseEntity handleExceptionInternalArgs(Exception e, HttpHeaders headers, ErrorStatus errorCommonStatus, - WebRequest request, Map errorArgs) { - ApiResponse body = ApiResponse.onFailure(errorCommonStatus.getCode(),errorCommonStatus.getMessage(),errorArgs); - return super.handleExceptionInternal( - e, - body, - headers, - errorCommonStatus.getHttpStatus(), - request - ); - } - - private ResponseEntity handleExceptionInternalConstraint(Exception e, ErrorStatus errorCommonStatus, - HttpHeaders headers, WebRequest request) { - ApiResponse body = ApiResponse.onFailure(errorCommonStatus.getCode(), errorCommonStatus.getMessage(), null); - return super.handleExceptionInternal( - e, - body, - headers, - errorCommonStatus.getHttpStatus(), - request - ); - } + @ExceptionHandler + public ResponseEntity validation(ConstraintViolationException e, WebRequest request) { + String errorMessage = e.getConstraintViolations().stream() + .map(constraintViolation -> constraintViolation.getMessage()) + .findFirst() + .orElseThrow(() -> new RuntimeException("ConstraintViolationException 추출 도중 에러 발생")); + + return handleExceptionInternalConstraint(e, ErrorStatus.valueOf(errorMessage), HttpHeaders.EMPTY, request); + } + + @Override + public ResponseEntity handleMethodArgumentNotValid(MethodArgumentNotValidException e, HttpHeaders headers, + HttpStatusCode status, WebRequest request) { + + Map errors = new LinkedHashMap<>(); + + e.getBindingResult().getFieldErrors().stream() + .forEach(fieldError -> { + String fieldName = fieldError.getField(); + String errorMessage = Optional.ofNullable(fieldError.getDefaultMessage()).orElse(""); + errors.merge(fieldName, errorMessage, + (existingErrorMessage, newErrorMessage) -> existingErrorMessage + ", " + newErrorMessage); + }); + + return handleExceptionInternalArgs(e, HttpHeaders.EMPTY, ErrorStatus.valueOf("_BAD_REQUEST"), request, errors); + } + + @ExceptionHandler + public ResponseEntity exception(Exception e, WebRequest request) { + e.printStackTrace(); + + return handleExceptionInternalFalse(e, ErrorStatus._INTERNAL_SERVER_ERROR, HttpHeaders.EMPTY, + ErrorStatus._INTERNAL_SERVER_ERROR.getHttpStatus(), request, e.getMessage()); + } + + @ExceptionHandler(value = GeneralException.class) + public ResponseEntity onThrowException(GeneralException generalException, HttpServletRequest request) { + ErrorReasonDTO errorReasonHttpStatus = generalException.getErrorReasonHttpStatus(); + return handleExceptionInternal(generalException, errorReasonHttpStatus, null, request); + } + + private ResponseEntity handleExceptionInternal(Exception e, ErrorReasonDTO reason, + HttpHeaders headers, HttpServletRequest request) { + + ApiResponse body = ApiResponse.onFailure(reason.getCode(), reason.getMessage(), null); + // e.printStackTrace(); + + WebRequest webRequest = new ServletWebRequest(request); + return super.handleExceptionInternal( + e, + body, + headers, + reason.getHttpStatus(), + webRequest + ); + } + + private ResponseEntity handleExceptionInternalFalse(Exception e, ErrorStatus errorCommonStatus, + HttpHeaders headers, HttpStatus status, WebRequest request, String errorPoint) { + ApiResponse body = ApiResponse.onFailure(errorCommonStatus.getCode(), errorCommonStatus.getMessage(), + errorPoint); + return super.handleExceptionInternal( + e, + body, + headers, + status, + request + ); + } + + private ResponseEntity handleExceptionInternalArgs(Exception e, HttpHeaders headers, + ErrorStatus errorCommonStatus, + WebRequest request, Map errorArgs) { + ApiResponse body = ApiResponse.onFailure(errorCommonStatus.getCode(), errorCommonStatus.getMessage(), + errorArgs); + return super.handleExceptionInternal( + e, + body, + headers, + errorCommonStatus.getHttpStatus(), + request + ); + } + + private ResponseEntity handleExceptionInternalConstraint(Exception e, ErrorStatus errorCommonStatus, + HttpHeaders headers, WebRequest request) { + ApiResponse body = ApiResponse.onFailure(errorCommonStatus.getCode(), errorCommonStatus.getMessage(), + null); + return super.handleExceptionInternal( + e, + body, + headers, + errorCommonStatus.getHttpStatus(), + request + ); + } } From 7666ba4fd376be3e150c7c96cf284fb1ae260048 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Fri, 11 Jul 2025 14:48:24 +0900 Subject: [PATCH 133/339] =?UTF-8?q?[Fix]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20DTO=20Converter=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=ED=95=98=EA=B2=8C=20=EC=88=98=EC=A0=95=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/auth/converter/AuthConverter.java | 14 ++++++++++++++ .../security/auth/filter/JwtLoginFilter.java | 6 ++---- .../spring/service/user/UserServiceImpl.java | 7 ++++--- 3 files changed, 20 insertions(+), 7 deletions(-) create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/auth/converter/AuthConverter.java diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/converter/AuthConverter.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/converter/AuthConverter.java new file mode 100644 index 0000000..066790f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/converter/AuthConverter.java @@ -0,0 +1,14 @@ +package PerfumeOnMe.spring.config.security.auth.converter; + +import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; + +public class AuthConverter { + + public static AuthResponseDTO.LoginResult toLoginResult(String refreshToken, Long userId) { + return AuthResponseDTO.LoginResult.builder() + .refreshToken(refreshToken) + .userId(userId) + .build(); + } + +} diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java index edab849..dda1f07 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java @@ -20,6 +20,7 @@ import PerfumeOnMe.spring.apiPayload.ApiResponse; import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.config.security.auth.converter.AuthConverter; import PerfumeOnMe.spring.config.security.auth.dto.AuthRequestDTO; import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; import PerfumeOnMe.spring.config.security.auth.manager.LogoutAccessTokenManager; @@ -85,10 +86,7 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR // 토큰 생성 및 DTO에 담기 String accessToken = jwtTokenProvider.createAccessToken(authResult); String refreshToken = jwtTokenProvider.createRefreshToken(authResult); - AuthResponseDTO.LoginResult loginResultDTO = AuthResponseDTO.LoginResult.builder() - .refreshToken(refreshToken) - .userId(userId) - .build(); + AuthResponseDTO.LoginResult loginResultDTO = AuthConverter.toLoginResult(refreshToken, userId); // 리프레시 토큰을 Redis에 저장 refreshTokenManager.saveRefreshToken(loginId, refreshToken); diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java index cc71212..190337c 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java @@ -10,11 +10,13 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.config.security.auth.converter.AuthConverter; import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; import PerfumeOnMe.spring.config.security.auth.manager.LogoutAccessTokenManager; import PerfumeOnMe.spring.config.security.auth.manager.RefreshTokenManager; import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; import PerfumeOnMe.spring.config.security.auth.token.JwtAuthenticationToken; +import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; import PerfumeOnMe.spring.converter.UserConverter; import PerfumeOnMe.spring.domain.User; import PerfumeOnMe.spring.repository.user.UserRepository; @@ -74,9 +76,8 @@ public AuthResponseDTO.LoginResult reissue(String reqRefreshToken, HttpServletRe JwtAuthenticationToken request = new JwtAuthenticationToken(userDetails, null, userDetails.getAuthorities()); String accessToken = jwtTokenProvider.createAccessToken(request); String refreshToken = jwtTokenProvider.createRefreshToken(request); - AuthResponseDTO.LoginResult loginResultDTO = AuthResponseDTO.LoginResult.builder() - .refreshToken(refreshToken) - .build(); + Long userId = ((CustomUserDetails)userDetails).getUserId(); + AuthResponseDTO.LoginResult loginResultDTO = AuthConverter.toLoginResult(refreshToken, userId); // 새로 발급한 리프레시 토큰을 Redis에 저장 - 덮어씌우기 refreshTokenManager.saveRefreshToken(loginId, refreshToken); From 60a59c03763188386701a6f77a57ba155c630e46 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Fri, 11 Jul 2025 14:49:06 +0900 Subject: [PATCH 134/339] =?UTF-8?q?[Feature]=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20=EA=B4=80=EB=A0=A8=20Exception=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EB=A1=9C=EA=B9=85=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/auth/filter/JwtExceptionHandlerFilter.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtExceptionHandlerFilter.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtExceptionHandlerFilter.java index bbb86cf..9c62669 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtExceptionHandlerFilter.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtExceptionHandlerFilter.java @@ -9,23 +9,21 @@ import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; -import com.fasterxml.jackson.databind.ObjectMapper; - import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; /* Security Filter에서 발생하는 예외를 잡아 통일해둔 API 응답에 맞게 처리하는 클래스 */ +@Slf4j @Component public class JwtExceptionHandlerFilter extends OncePerRequestFilter { - private final ObjectMapper mapper = new ObjectMapper(); - @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { @@ -39,6 +37,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } catch (BadCredentialsException e) { setErrorResponse(response, ErrorStatus.PASSWORD_NOT_MATCH, e); } catch (Exception e) { + log.error(e.getMessage(), e); // Exception Logging setErrorResponse(response, ErrorStatus._INTERNAL_SERVER_ERROR, e); } } From f09c48aa54251523e139a764a7f4be4c9b298ae3 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Fri, 11 Jul 2025 14:50:47 +0900 Subject: [PATCH 135/339] =?UTF-8?q?[Feature]=20OAuth2=20=EC=9D=98=EC=A1=B4?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20CICD=20=EA=B4=80=EB=A0=A8=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 6076034..2e4154e 100644 --- a/build.gradle +++ b/build.gradle @@ -73,8 +73,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' // Query Parameter Log implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' - //Apache POI + // Apache POI implementation 'org.apache.poi:poi-ooxml:5.2.3' + // OAuth2.0 + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' } tasks.named('test') { @@ -83,4 +85,8 @@ tasks.named('test') { clean { delete file('src/main/generated') +} + +jar { + enabled = false } \ No newline at end of file From 8ed805b986b61388950fa50e6fb31a794f90cf7f Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Fri, 11 Jul 2025 14:51:47 +0900 Subject: [PATCH 136/339] =?UTF-8?q?[Feature]=20=EC=99=B8=EB=B6=80=20API=20?= =?UTF-8?q?=ED=86=B5=EC=8B=A0=EC=9A=A9=20RestTemplateConfig=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/config/RestTemplateConfig.java | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/RestTemplateConfig.java diff --git a/src/main/java/PerfumeOnMe/spring/config/RestTemplateConfig.java b/src/main/java/PerfumeOnMe/spring/config/RestTemplateConfig.java new file mode 100644 index 0000000..c6ffdb3 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/RestTemplateConfig.java @@ -0,0 +1,34 @@ +package PerfumeOnMe.spring.config; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.converter.FormHttpMessageConverter; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.web.client.RestTemplate; + +/* +외부 API 통신 + */ +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + RestTemplate restTemplate = new RestTemplate(); + + // Message Converter 커스텀 + List> converters = new ArrayList<>(); + converters.add(new StringHttpMessageConverter(StandardCharsets.UTF_8)); + converters.add(new FormHttpMessageConverter()); + converters.add(new MappingJackson2HttpMessageConverter()); + restTemplate.setMessageConverters(converters); + + return restTemplate; + } +} From 8d28aa8bd7ec4cf96b1e41217bcbcb1df11e3bc6 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 11 Jul 2025 16:41:59 +0900 Subject: [PATCH 137/339] =?UTF-8?q?[Fix]=20=EC=9D=B5=EB=AA=85=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=20=ED=97=88=EC=9A=A9=20=EA=B2=BD=EB=A1=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20JWT=20=EC=9D=B8=EC=A6=9D=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EB=B3=80=EA=B2=BD=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/config/security/SecurityConfig.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java b/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java index dcbfb81..7c34d68 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java @@ -30,7 +30,7 @@ public class SecurityConfig { public static final String[] AUTH_WHITELIST = { "/v3/api-docs/**", "/swagger-resources/**", "/swagger-ui.html", "/swagger-ui/**", "/swagger/**", "/users/signup", "/auth/login", "/auth/social/kakao", "/users/reissue", - "/health" + "/health", "/fragrances/allow/**" }; private final JwtAuthenticationFilter JwtAuthenticationFilter; private final JwtExceptionHandlerFilter JwtExceptionHandlerFilter; @@ -44,7 +44,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 요청 경로별 인증 확인 설정 .authorizeHttpRequests(auth -> auth .requestMatchers(AUTH_WHITELIST).permitAll() - .anyRequest().permitAll() // 개발 진행할 때 임시로 풀어두기 -> 나중에 authenticated()로 변경 + .anyRequest().authenticated() // 개발 진행할 때 임시로 풀어두기 -> 나중에 authenticated()로 변경 ) // filter 레벨에서 발생하는 예외 핸들러 설정 .exceptionHandling(exception -> exception From 04ddafd7f08b67fe46a6c47712620cf0cc5615f6 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 11 Jul 2025 16:42:31 +0900 Subject: [PATCH 138/339] =?UTF-8?q?[Feature]=20=ED=96=A5=EC=88=98=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20DTO=20=EC=83=9D=EC=84=B1=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/web/dto/fragrance/FragranceRequestDTO.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceRequestDTO.java index 2d732f5..4cc48e9 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceRequestDTO.java @@ -50,4 +50,15 @@ public static class FragranceFilterRequest { } + // 향수 전체 리스트 요청 DTO + @Getter + @Setter + public static class FragranceAllRequest { + @ValidPage + private int page; + + @ValidSize + private int size; + } + } From bd5faf0002dfdffe9467a97e44a239ac81372093 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 11 Jul 2025 16:45:34 +0900 Subject: [PATCH 139/339] =?UTF-8?q?[Feature]=20getFragranceListAll()=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=20=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/service/fragrance/FragranceService.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java index 3d8adc7..a1b6ff4 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java @@ -8,7 +8,8 @@ public interface FragranceService { FragranceResponseDTO.FragranceDetailResult getFragranceDetail(Long fragranceId, Long userId); // 향수 검색 API - FragranceResponseDTO.FragranceSearchFinalResult searchFragrances(String keyword, int page, int size, Long userId); + FragranceResponseDTO.FragranceSearchFinalResult searchFragrances(FragranceRequestDTO.FragranceSearchRequest request, + Long userId); // 향수 즐겨찾기 등록 API FragranceResponseDTO.FavoriteResponseDTO addFavorite(Long userId, Long fragranceId); @@ -19,5 +20,9 @@ public interface FragranceService { // 향수 필터링 API FragranceResponseDTO.FragranceSearchFinalResult searchFragrancesByFilter( FragranceRequestDTO.FragranceFilterRequest request, Long userId); + + // 향수 전체 리스트 조회 API + FragranceResponseDTO.FragranceSearchFinalResult getFragranceListAll(FragranceRequestDTO.FragranceAllRequest request, + Long userId); } From 83906441918a5f03b011162db8500e3e543ea3b0 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 11 Jul 2025 16:46:08 +0900 Subject: [PATCH 140/339] =?UTF-8?q?[Feature]=20=ED=96=A5=EC=88=98=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fragrance/FragranceServiceImpl.java | 34 ++++++++++++++--- .../web/controller/FragranceController.java | 38 +++++++++++++++---- 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java index edf5969..3093ea7 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java @@ -46,22 +46,23 @@ public FragranceResponseDTO.FragranceDetailResult getFragranceDetail(Long fragra Fragrance fragrance = fragranceRepository.findByIdWithAllDetails(fragranceId) .orElseThrow(() -> new GeneralException(ErrorStatus.FRAGRANCE_NOT_FOUND)); - boolean liked = Like(userId, fragranceId); + boolean liked = (userId != null) && Like(userId, fragranceId); return FragranceConverter.toDetailDto(fragrance, liked); } // 향수 검색 API @Override - public FragranceResponseDTO.FragranceSearchFinalResult searchFragrances(String keyword, int page, int size, + public FragranceResponseDTO.FragranceSearchFinalResult searchFragrances( + FragranceRequestDTO.FragranceSearchRequest request, Long userId) { - PageRequest pageable = PageRequest.of(page, size); - Page fragrancePage = fragranceRepository.findBySearchKeyword(keyword, pageable); + PageRequest pageable = PageRequest.of(request.getPage(), request.getSize()); + Page fragrancePage = fragranceRepository.findBySearchKeyword(request.getKeyword(), pageable); List content = fragrancePage.getContent().stream() .map(fragrance -> { // 즐겨찾기 확인 - boolean liked = Like(userId, fragrance.getId()); + boolean liked = (userId != null) && Like(userId, fragrance.getId()); return FragranceConverter.toSearchResultDto(fragrance, liked); }) .collect(Collectors.toList()); @@ -156,7 +157,28 @@ public FragranceResponseDTO.FragranceSearchFinalResult searchFragrancesByFilter( List content = fragrancePage.getContent().stream() .map(fragrance -> { // 즐겨찾기 확인 - boolean liked = Like(userId, fragrance.getId()); + boolean liked = (userId != null) && Like(userId, fragrance.getId()); + return FragranceConverter.toSearchResultDto(fragrance, liked); + }) + .collect(Collectors.toList()); + + return FragranceResponseDTO.FragranceSearchFinalResult.builder() + .content(content) + .hasNext(fragrancePage.hasNext()) + .build(); + } + + // 향수 전체 리스트 API + @Override + public FragranceResponseDTO.FragranceSearchFinalResult getFragranceListAll( + FragranceRequestDTO.FragranceAllRequest request, Long userId) { + PageRequest pageable = PageRequest.of(request.getPage(), request.getSize()); + Page fragrancePage = fragranceRepository.findAll(pageable); // 향수 전체 목록 가져오기 + + List content = fragrancePage.getContent().stream() + .map(fragrance -> { + // 즐겨찾기 확인 + boolean liked = (userId != null) && Like(userId, fragrance.getId()); return FragranceConverter.toSearchResultDto(fragrance, liked); }) .collect(Collectors.toList()); diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java index 0b671c5..a3db993 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java @@ -35,7 +35,7 @@ public class FragranceController { /** * 향수 상세 API */ - @GetMapping("/{fragranceId}") + @GetMapping("/allow/{fragranceId}") @Operation( summary = "향수 상세 조회", description = "향수 ID로 상세 정보를 조회하는 API입니다.", @@ -50,15 +50,16 @@ public class FragranceController { public ResponseEntity> getFragranceDetail( @PathVariable("fragranceId") Long fragranceId, @AuthenticationPrincipal CustomUserDetails userDetails) { + Long userId = (userDetails != null) ? userDetails.getUserId() : null; FragranceResponseDTO.FragranceDetailResult result = fragranceService.getFragranceDetail(fragranceId, - userDetails.getUserId()); + userId); return ResponseEntity.ok(ApiResponse.onSuccess(result)); } /** * 향수 검색 API */ - @GetMapping("/search") + @GetMapping("/allow/search") @Operation( summary = "향수 키워드 검색 (무한 스크롤)", description = "keyword 로 향수 이름을 검색하고, 페이징 처리된 결과를 반환합니다.", @@ -76,9 +77,8 @@ public ResponseEntity> getFragrancesAll( + FragranceRequestDTO.FragranceAllRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Long userId = (userDetails != null) ? userDetails.getUserId() : null; + FragranceResponseDTO.FragranceSearchFinalResult result = fragranceService.getFragranceListAll(request, + userId); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } } From 9bcdcd415d44d91e466ee5a0ebdbd5017c23e870 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 13 Jul 2025 18:12:57 +0900 Subject: [PATCH 141/339] [CI/CD] ci/cd(#41) --- .github/workflows/dev_deploy.yml | 111 +++++++++++++++---------- .gitignore | 3 +- Dockerfile | 26 ++++++ src/main/resources/application-dev.yml | 4 +- 4 files changed, 100 insertions(+), 44 deletions(-) create mode 100644 Dockerfile diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index 493e301..06d1bdf 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -1,61 +1,88 @@ -name: Perfume on Me Dev CI/CD +name: PerfumeonMe Dev CI/CD on: - pull_request: - types: [closed] - workflow_dispatch: # (2).수동 실행도 가능하도록 - + push: + branches: [ "develop" ] jobs: - build: - runs-on: ubuntu-latest # (3). CI/CD 작업을 수행할 OS환경 / 하단의 develop을 release로 바꾸면 배포 스크립트 - if: github.event.pull_request.merged == true && github.event.pull_request.base.ref == 'develop' + deploy: + runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 # (4).코드 check out + uses: actions/checkout@v3 - name: Set up JDK 21 uses: actions/setup-java@v3 with: - java-version: 21 # (5).자바 설치 + java-version: '21' distribution: 'adopt' + - name: Copy Secret + env: + OCCUPY_SECRET: ${{ secrets.OCCUPY_SECRET }} + OCCUPY_SECRET_DIR: src/main/resources + OCCUPY_SECRET_DIR_FILE_NAME: application-secret.yml + run: echo $OCCUPY_SECRET | base64 --decode > $OCCUPY_SECRET_DIR/$OCCUPY_SECRET_DIR_FILE_NAME + + - name: gradlew mod modify + run: chmod +x gradlew - - name: Grant execute permission for gradlew - run: chmod +x ./gradlew - shell: bash # (6).권한 부여 + # gradle 캐싱 (0) - 주석처리할수도있음 + - name: Gradle Caching + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- - - name: Build with Gradle - run: ./gradlew clean build -x test - shell: bash # (7).build시작 + # Spring Boot 어플리케이션 BUILD(1) + - name: Spring Boot Build + run: ./gradlew clean build --exclude-task test - - name: Get current time - uses: 1466587594/get-current-time@v2 - id: current-time + - name: Docker Image Build + run: docker build -t chanee29/perfumeonme . + + - name: docker login + uses: docker/login-action@v2 with: - format: YYYY-MM-DDTHH-mm-ss - utcOffset: "+09:00" # (8).build시점의 시간확보 + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: docker Hub Push + run: docker push chanee29/perfumeonme - - name: Show Current Time - run: echo "CurrentTime=${{ steps.current-time.outputs.formattedTime }}" - shell: bash # (9).확보한 시간 보여주기 + - name: get GitHub IP + id: ip + uses: haythem/public-ip@v1.2 - - name: Generate deployment package + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v1 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_ACCESS_KEY_PASSWORD }} + aws-region: ap-northeast-1 + + - name: Add GitHub IP to AWS run: | - mkdir -p deploy - cp build/libs/*.jar deploy/application.jar - cp Procfile deploy/Procfile - cp -r .ebextensions_dev deploy/.ebextensions - cp -r .platform deploy/.platform - cd deploy && zip -r deploy.zip . - - - name: Beanstalk Deploy - uses: einaregilsson/beanstalk-deploy@v20 + aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 + + - name: AWS EC2 Connection + uses: appleboy/ssh-action@v0.1.6 with: - aws_access_key: ${{ secrets.AWS_ACTION_ACCESS_KEY_ID }} - aws_secret_key: ${{ secrets.AWS_ACTION_SECRET_ACCESS_KEY }} - application_name: perfume-dev-app - environment_name: Perfume-dev-app-env - version_label: github-action-${{ steps.current-time.outputs.formattedTime }} - region: ap-northeast-1 - deployment_package: deploy/deploy.zip - wait_for_deployment: false \ No newline at end of file + host: ${{ secrets.EC2_HOST }} + username: ubuntu + key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} + port: ${{ secrets.EC2_SSH_PORT }} + timeout: 60s + script: | + sudo docker stop perfumeonme || true + sudo docker rm perfumeonme || true + sudo docker rmi chanee29/perfumeonme || true + sudo docker pull chanee29/perfumeonme + sudo docker run -d -p 8080:8080 --name perfumeonme chanee29/perfumeonme + + - name: Remove GitHub IP FROM security group + run: | + aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5a6d032..2c46216 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ out/ .vscode/ ### application-local.yml ### -src/main/resources/application-local.yml \ No newline at end of file +src/main/resources/application-local.yml +src/main/resources/application-secret.yml \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7cf97db --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# 1단계: Gradle을 이용한 빌드용 이미지 +FROM gradle:8.4.0-jdk21 AS builder + +# 작업 디렉토리 설정 +WORKDIR /app + +# 전체 프로젝트 복사 +COPY . . + +# 종속성 캐시 및 빌드 +RUN gradle clean build -x test + +# ------------------------------------------------------ + +# 2단계: 실제 애플리케이션 실행용 이미지 +FROM eclipse-temurin:21-jdk + +# JAR 복사 (빌드된 JAR 경로) +ARG JAR_FILE=build/libs/*.jar +COPY --from=builder /app/${JAR_FILE} app.jar + +# 8080 포트 오픈 +EXPOSE 8080 + +# 앱 실행 명령어 +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 1fde84c..2acc93f 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -2,6 +2,8 @@ spring: config: activate: on-profile: dev + import: + - application-secret.yml datasource: url: # RDS username: # Username @@ -28,7 +30,7 @@ spring: type: redis jwt: token: - secretKey: # secret key + secretKey: ${jwt.secret.secret_key} expiration: access: 7200000 # 2시간 refresh: 1209600000 # 2주 From 15971b83fe8d217c2c511d94f6e79bf65f5ce93d Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 13 Jul 2025 18:17:54 +0900 Subject: [PATCH 142/339] [CI/CD] ci/cd(#41) --- src/main/resources/application-dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 2acc93f..2721225 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -5,7 +5,7 @@ spring: import: - application-secret.yml datasource: - url: # RDS + url: # RDS1 username: # Username password: # Password driver-class-name: com.mysql.cj.jdbc.Driver From 4c40526bb7d5e298db2b352b8310b97fd9e74ef8 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 13 Jul 2025 18:28:55 +0900 Subject: [PATCH 143/339] [CI/CD] ci/cd(#41) --- src/main/resources/application-dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 2721225..2acc93f 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -5,7 +5,7 @@ spring: import: - application-secret.yml datasource: - url: # RDS1 + url: # RDS username: # Username password: # Password driver-class-name: com.mysql.cj.jdbc.Driver From d9202aafaa1bcd7606799982e9912b98f96a2367 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Sun, 13 Jul 2025 21:16:57 +0900 Subject: [PATCH 144/339] =?UTF-8?q?[Feature]=20Spring=20WebFlux=20?= =?UTF-8?q?=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 6076034..882a130 100644 --- a/build.gradle +++ b/build.gradle @@ -73,8 +73,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' // Query Parameter Log implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' - //Apache POI + // Apache POI implementation 'org.apache.poi:poi-ooxml:5.2.3' + // WebClient + implementation 'org.springframework.boot:spring-boot-starter-webflux' } tasks.named('test') { From 7af730d6631cfd9c34e8864855999b5cee542ec8 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Sun, 13 Jul 2025 21:17:14 +0900 Subject: [PATCH 145/339] =?UTF-8?q?[Feature]=20=ED=94=84=EB=A1=AC=ED=94=84?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/prompts/expert.txt | 41 +++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 src/main/resources/prompts/expert.txt diff --git a/src/main/resources/prompts/expert.txt b/src/main/resources/prompts/expert.txt new file mode 100644 index 0000000..04194e9 --- /dev/null +++ b/src/main/resources/prompts/expert.txt @@ -0,0 +1,41 @@ +⚙️ 시스템 메시지 + +너는 향수 추천 플랫폼의 챗봇이야. +사용자가 말한 사물이나 장면을 통해 연상되는 향기를 이해하고, +그에 어울리는 가장 유사한 향수를 3가지 추천해줘. +말투는 딱딱하지 않게. 친한 친구처럼. 반말도 섞어가면서 아주 친근하게 해줘. + +각 향수는 다음 정보를 포함해야 해: + +- 이름 +- 브랜드 +- 주요 향 노트 (예: 시트러스, 머스크 등) +- 사용자 입력에 대한 감각적 비유 설명 (2문장 이내, 간결하고 친근한 말투) + +예시 입력: “지하주차장 냄새 같은 향수 알려줘” + +출력 형식 (이 형식을 절대 벗어나지 마) + +1. Perfume 1 + - 이름: `<향수 이름>` + - 브랜드: `<브랜드>` + - 주요 향 노트: `<노트>` + - 비유 설명: "`<사용자 입력>`처럼 …" + +2. Perfume 2 + - 이름: + - 브랜드: + - 주요 향 노트: + - 비유 설명: + +3. Perfume 3 + - 이름: + - 브랜드: + - 주요 향 노트: + - 비유 설명: + +주의 사항 +1. **반드시 향수는 3개 추천할 것** +2. **지정된 출력 형식만 사용하고, 그 외 설명은 절대 하지 마** +3. **말투는 친근하고 짧게** + From ffe2fc9540d416203ec849d758d6bce5a3b32608 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Sun, 13 Jul 2025 21:17:31 +0900 Subject: [PATCH 146/339] =?UTF-8?q?[Feature]=20=EC=B1=97=EB=B4=87=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EA=B4=80=EB=A0=A8=20enum=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/apiPayload/code/status/ErrorStatus.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index 14f91de..558f3d0 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -54,6 +54,11 @@ public enum ErrorStatus implements BaseErrorCode { INVALID_SITUATION_ID(HttpStatus.BAD_REQUEST, "FILTER4005", "유효하지 않은 장소 ID 입니다."), INVALID_PRICE_RANGE(HttpStatus.BAD_REQUEST, "FILTER4006", "가격 범위가 올바르지 않습니다."), + // 챗봇 에러 + FILE_NOT_FOUND(HttpStatus.BAD_REQUEST, "CHATBOT4001", "프롬프트 파일을 찾을 수 없습니다."), + PROMPT_LOADING_FAIL(HttpStatus.BAD_REQUEST, "CHATBOT4002", "프롬프트 로딩에 실패하였습니다."), + REQUIRED_MESSAGES(HttpStatus.BAD_REQUEST, "CHATBOT4003", "메세지를 입력하세요."), + // 예시,,, ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); From 73c85135e9fde18627407dc6a99be46060274e02 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Sun, 13 Jul 2025 21:18:07 +0900 Subject: [PATCH 147/339] =?UTF-8?q?[Feature]=20=ED=94=84=EB=A1=AC=ED=94=84?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=83=9D=EC=84=B1=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/service/chatbot/PromptLoader.java | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/service/chatbot/PromptLoader.java diff --git a/src/main/java/PerfumeOnMe/spring/service/chatbot/PromptLoader.java b/src/main/java/PerfumeOnMe/spring/service/chatbot/PromptLoader.java new file mode 100644 index 0000000..770920f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/chatbot/PromptLoader.java @@ -0,0 +1,26 @@ +package PerfumeOnMe.spring.service.chatbot; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import org.springframework.stereotype.Component; + +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; + +@Component +public class PromptLoader { + + private final String defaultPromptFile = "expert.txt"; + + public String loadDefaultPrompt() { + try (InputStream is = getClass().getClassLoader().getResourceAsStream("prompts/" + defaultPromptFile)) { + if (is == null) + throw new GeneralException(ErrorStatus.FILE_NOT_FOUND); // 파일을 찾을 수 없음 + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new GeneralException(ErrorStatus.PROMPT_LOADING_FAIL); // 프롬프트 로딩 실패 + } + } +} From ad41e41a037e118c1739fdc585106a72b84bcf21 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Sun, 13 Jul 2025 21:19:12 +0900 Subject: [PATCH 148/339] =?UTF-8?q?[Feature]=20OpenAI=20API=20=ED=86=B5?= =?UTF-8?q?=EC=8B=A0=EC=9D=84=20=EC=9C=84=ED=95=9C=20WebClient=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/config/OpenAIConfig.java | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/OpenAIConfig.java diff --git a/src/main/java/PerfumeOnMe/spring/config/OpenAIConfig.java b/src/main/java/PerfumeOnMe/spring/config/OpenAIConfig.java new file mode 100644 index 0000000..a1704e3 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/OpenAIConfig.java @@ -0,0 +1,22 @@ +package PerfumeOnMe.spring.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class OpenAIConfig { + + @Value("${openai.api-key}") + private String apiKey; + + @Bean + public WebClient openAiWebClient() { + return WebClient.builder() + .baseUrl("https://api.openai.com/v1") + .defaultHeader("Authorization", "Bearer " + apiKey) + .defaultHeader("Content-Type", "application/json") + .build(); + } +} \ No newline at end of file From 278c68e1a2771ef98cd27d6fc61312ca8b410fad Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Sun, 13 Jul 2025 21:19:39 +0900 Subject: [PATCH 149/339] =?UTF-8?q?[Fix]=20ChatMessage=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=ED=95=84=EB=93=9C=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/domain/ChatMessage.java | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/domain/ChatMessage.java b/src/main/java/PerfumeOnMe/spring/domain/ChatMessage.java index 1ebf275..8e0e21e 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/ChatMessage.java +++ b/src/main/java/PerfumeOnMe/spring/domain/ChatMessage.java @@ -1,14 +1,13 @@ package PerfumeOnMe.spring.domain; +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import PerfumeOnMe.spring.domain.base.BaseEntity; -import PerfumeOnMe.spring.domain.enums.ChatSender; import jakarta.persistence.Column; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -30,19 +29,22 @@ @DynamicInsert @DynamicUpdate @Table(name = "chat_messages") -public class ChatMessage extends BaseEntity { +public class ChatMessage { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") + @JoinColumn(name = "user_id", nullable = false) private User user; @Column(columnDefinition = "TEXT", nullable = false) - private String content; + private String userMessage; + + @Column(columnDefinition = "TEXT", nullable = false) + private String botResponse; - @Enumerated(EnumType.STRING) - @Column(columnDefinition = "VARCHAR(10)", nullable = false) - private ChatSender senderType; + @CreationTimestamp + private LocalDateTime createdAt; } From 597c761122ac299387908ca63520f344f94c4ed4 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Sun, 13 Jul 2025 21:59:22 +0900 Subject: [PATCH 150/339] =?UTF-8?q?[Feature]=20Chatbot=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD,=EC=9D=91=EB=8B=B5=20=EA=B4=80=EB=A0=A8=20DTO=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/dto/chatbot/ChatBotRequestDTO.java | 30 +++++++++++++++++ .../web/dto/chatbot/ChatBotResponseDTO.java | 32 +++++++++++++++++++ .../dto/chatbot/ChatCompletionMessage.java | 19 +++++++++++ .../dto/chatbot/ChatCompletionRequest.java | 19 +++++++++++ 4 files changed, 100 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatBotRequestDTO.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatBotResponseDTO.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatCompletionMessage.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatCompletionRequest.java diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatBotRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatBotRequestDTO.java new file mode 100644 index 0000000..a0dfa5c --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatBotRequestDTO.java @@ -0,0 +1,30 @@ +package PerfumeOnMe.spring.web.dto.chatbot; + +import PerfumeOnMe.spring.validation.annotation.ValidPage; +import PerfumeOnMe.spring.validation.annotation.ValidSize; +import lombok.Getter; +import lombok.Setter; + +public class ChatBotRequestDTO { + + @Getter + @Setter + public static class ChatBotQARequest { + private String message; + } + + @Getter + @Setter + public static class ChatBotPagingRequest { + @ValidPage + private int page; + + @ValidSize + private int size; + } + + // @ModelAttribute 또는 기본 파라미터 바인딩을 사용하는 DTO 는 + // @Getter, @Setter 모두 있어야 값 주입 + 조회가 가능. + // @Setter 가 없으면 값은 주입되지 않고, getPage() or getSize() 는 기본값을 반환. + +} diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatBotResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatBotResponseDTO.java new file mode 100644 index 0000000..b415974 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatBotResponseDTO.java @@ -0,0 +1,32 @@ +package PerfumeOnMe.spring.web.dto.chatbot; + +import java.time.LocalDateTime; +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class ChatBotResponseDTO { + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class ChatBotQA { + private String userMessage; + private String botResponse; + private LocalDateTime createdAt; + } + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class ChatBotFinalResponse { + private List content; + private boolean hasNext; + } + +} diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatCompletionMessage.java b/src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatCompletionMessage.java new file mode 100644 index 0000000..801fdc8 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatCompletionMessage.java @@ -0,0 +1,19 @@ +package PerfumeOnMe.spring.web.dto.chatbot; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ChatCompletionMessage { + + // "system" GPT 의 행동 방식을 지정하는 지침 (초기 설정) + // "user" 사용자가 입력한 질문, 요청, 대화 + // "assistant" GPT 가 응답한 내용 (이전 응답들) + private String role; // "system" | "user" | "assistant" + private String content; // 프롬프트, 사용자의 질문. 챗봇 응답 +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatCompletionRequest.java b/src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatCompletionRequest.java new file mode 100644 index 0000000..6dfd01c --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatCompletionRequest.java @@ -0,0 +1,19 @@ +package PerfumeOnMe.spring.web.dto.chatbot; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class ChatCompletionRequest { + private String model; // ex: "gpt-3.5-turbo" + private List messages; +} \ No newline at end of file From a35ac683b186cf3bfff4d085eaf0bd0c4f47122c Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Sun, 13 Jul 2025 21:59:43 +0900 Subject: [PATCH 151/339] =?UTF-8?q?[Feature]=20Chatbot=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/converter/ChatConverter.java | 25 ++++ .../chatbot/ChatMessageRepository.java | 17 +++ .../service/chatbot/ChatbotService.java | 13 ++ .../service/chatbot/ChatbotServiceImpl.java | 111 ++++++++++++++++++ .../web/controller/ChatbotController.java | 69 +++++++++++ 5 files changed, 235 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/converter/ChatConverter.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/chatbot/ChatMessageRepository.java create mode 100644 src/main/java/PerfumeOnMe/spring/service/chatbot/ChatbotService.java create mode 100644 src/main/java/PerfumeOnMe/spring/service/chatbot/ChatbotServiceImpl.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/controller/ChatbotController.java diff --git a/src/main/java/PerfumeOnMe/spring/converter/ChatConverter.java b/src/main/java/PerfumeOnMe/spring/converter/ChatConverter.java new file mode 100644 index 0000000..cd03dca --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/converter/ChatConverter.java @@ -0,0 +1,25 @@ +package PerfumeOnMe.spring.converter; + +import java.util.List; +import java.util.stream.Collectors; + +import PerfumeOnMe.spring.domain.ChatMessage; +import PerfumeOnMe.spring.web.dto.chatbot.ChatBotResponseDTO; + +public class ChatConverter { + // 챗봇과 사용자의 대화 단건 저장 + public static ChatBotResponseDTO.ChatBotQA toDto(ChatMessage cm) { + return ChatBotResponseDTO.ChatBotQA.builder() + .userMessage(cm.getUserMessage()) + .botResponse(cm.getBotResponse()) + .createdAt(cm.getCreatedAt()) + .build(); + } + + // toDto 를 통해 반환된 단건 대화들의 리스트를 반환하는 메서드 + public static List toDtoList(List list) { + return list.stream() + .map(ChatConverter::toDto) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/chatbot/ChatMessageRepository.java b/src/main/java/PerfumeOnMe/spring/repository/chatbot/ChatMessageRepository.java new file mode 100644 index 0000000..0f78ece --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/chatbot/ChatMessageRepository.java @@ -0,0 +1,17 @@ +package PerfumeOnMe.spring.repository.chatbot; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.domain.ChatMessage; + +public interface ChatMessageRepository extends JpaRepository { + // 챗봇 대화 맥락용(10개) + List findTop10ByUserIdOrderByCreatedAtDesc(Long userId); + + // 대화 전체 이력 페이징 + Page findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/service/chatbot/ChatbotService.java b/src/main/java/PerfumeOnMe/spring/service/chatbot/ChatbotService.java new file mode 100644 index 0000000..1934adc --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/chatbot/ChatbotService.java @@ -0,0 +1,13 @@ +package PerfumeOnMe.spring.service.chatbot; + +import PerfumeOnMe.spring.web.dto.chatbot.ChatBotRequestDTO; +import PerfumeOnMe.spring.web.dto.chatbot.ChatBotResponseDTO; +import reactor.core.publisher.Mono; + +public interface ChatbotService { + // 대화 이력 조회 + ChatBotResponseDTO.ChatBotFinalResponse getChatHistory(Long userId, ChatBotRequestDTO.ChatBotPagingRequest request); + + // 챗봇과 질의 응답 + Mono ask(Long userId, ChatBotRequestDTO.ChatBotQARequest request); +} diff --git a/src/main/java/PerfumeOnMe/spring/service/chatbot/ChatbotServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/chatbot/ChatbotServiceImpl.java new file mode 100644 index 0000000..fc72d21 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/chatbot/ChatbotServiceImpl.java @@ -0,0 +1,111 @@ +package PerfumeOnMe.spring.service.chatbot; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.reactive.function.client.WebClient; + +import com.fasterxml.jackson.databind.JsonNode; + +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.converter.ChatConverter; +import PerfumeOnMe.spring.domain.ChatMessage; +import PerfumeOnMe.spring.domain.User; +import PerfumeOnMe.spring.repository.chatbot.ChatMessageRepository; +import PerfumeOnMe.spring.repository.user.UserRepository; +import PerfumeOnMe.spring.web.dto.chatbot.ChatBotRequestDTO; +import PerfumeOnMe.spring.web.dto.chatbot.ChatBotResponseDTO; +import PerfumeOnMe.spring.web.dto.chatbot.ChatCompletionMessage; +import PerfumeOnMe.spring.web.dto.chatbot.ChatCompletionRequest; +import lombok.RequiredArgsConstructor; +import reactor.core.publisher.Mono; + +@Service +@RequiredArgsConstructor +@Transactional +public class ChatbotServiceImpl implements ChatbotService { + private final WebClient openAiWebClient; // OpenAI API 를 호출하기 위한 HTTP 클라이언트 + private final PromptLoader promptLoader; // 지정해둔 프롬프트 파일(resources/prompts/expert.txt)을 읽어오는 유틸 + private final ChatMessageRepository chatMessageRepository; // 사용자-챗봇의 대화 이력을 DB에 저장하기 위한 Repository + private final UserRepository userRepository; + + @Value("${openai.model}") + private String model; // OpenAI 모델 이름 - gpt-3.5-turbo + + /** + * userId: 현재 로그인한 사용자 ID + * request: 사용자 질문이 담긴 DTO + * Mono: 비동기적으로 OpenAI 응답을 받아서 리턴 + * */ + @Override + public Mono ask(Long userId, ChatBotRequestDTO.ChatBotQARequest request) { + if (request.getMessage() == null) { + throw new GeneralException(ErrorStatus.REQUIRED_MESSAGES); + } + + String systemPrompt = promptLoader.loadDefaultPrompt(); // 프롬프트 파일 로딩 + + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + + // 과거 대화 이력 10개 가져오기 (최신순 정렬 → 다시 역순 정렬 필요) + List history = chatMessageRepository.findTop10ByUserIdOrderByCreatedAtDesc(userId); + Collections.reverse(history); // 오래된 대화부터 시작하도록 정렬 + + List messages = new ArrayList<>(); + messages.add(new ChatCompletionMessage("system", systemPrompt)); + + for (ChatMessage msg : history) { + messages.add(new ChatCompletionMessage("user", msg.getUserMessage())); + messages.add(new ChatCompletionMessage("assistant", msg.getBotResponse())); + } + + // 현재 사용자의 질문 추가 + messages.add(new ChatCompletionMessage("user", request.getMessage())); + + ChatCompletionRequest openAiRequest = ChatCompletionRequest.builder() + .model(model) // OpenAI 모델 + .messages(messages) + .build(); + + return openAiWebClient.post() + .uri("/chat/completions") // OpenAI의 채팅 응답 API 엔드포인트 + .bodyValue(openAiRequest)// 위에서 만든 요청 객체 전송 + .retrieve() + .bodyToMono(JsonNode.class) // 응답을 JSON 트리로 받음 + .map(json -> json.get("choices").get(0).get("message").get("content").asText()) + .map(response -> { + // 사용자의 질문과 OpenAI의 응답을 ChatMessage 로 묶어서 DB 저장 + ChatMessage chat = ChatMessage.builder() + .user(user) + .userMessage(request.getMessage()) + .botResponse(response) + .build(); + chatMessageRepository.save(chat); // chatMessageRepository 에 사용자와 챗봇의 대화 이력을 저장 + return response; + }); + } + + /** + * 대화 이력 조회 + * */ + @Override + public ChatBotResponseDTO.ChatBotFinalResponse getChatHistory(Long userId, + ChatBotRequestDTO.ChatBotPagingRequest request) { + PageRequest pageable = PageRequest.of(request.getPage(), request.getSize(), + Sort.by(Sort.Direction.DESC, "createdAt")); + Page chats = chatMessageRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); + + List content = ChatConverter.toDtoList(chats.getContent()); + + return new ChatBotResponseDTO.ChatBotFinalResponse(content, chats.hasNext()); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/ChatbotController.java b/src/main/java/PerfumeOnMe/spring/web/controller/ChatbotController.java new file mode 100644 index 0000000..5b305f0 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/controller/ChatbotController.java @@ -0,0 +1,69 @@ +package PerfumeOnMe.spring.web.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; +import PerfumeOnMe.spring.service.chatbot.ChatbotService; +import PerfumeOnMe.spring.web.dto.chatbot.ChatBotRequestDTO; +import PerfumeOnMe.spring.web.dto.chatbot.ChatBotResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import reactor.core.publisher.Mono; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/chatbot") +@Tag(name = "Chatbot", description = "챗봇 CRUD API") +public class ChatbotController { + private final ChatbotService chatbotService; + + // 로그인한 사용자가 챗봇에게 질문을 보내고, + // OpenAI 로부터 받은 응답을 클라이언트에게 반환하는 API + @PostMapping + @Operation( + summary = "챗봇 질의 응답", + description = "챗봇에게 질문을 하고 OpenAI 로부터 받은 응답을 반환하는 API 입니다." + ) + @Parameters({ + @Parameter(name = "message", description = "사용자가 질문할 내용"), + }) + public Mono>> ask( + @RequestBody ChatBotRequestDTO.ChatBotQARequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + return chatbotService.ask(userDetails.getUserId(), request) + .map(answer -> ResponseEntity.ok(ApiResponse.onSuccess(answer))); + } + + // 대화 이력을 반환하는 API + @GetMapping("/history") + @Operation( + summary = "챗봇 대화 이력 조회", + description = "챗봇 대화 이력을 조회하는 API 입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ChatBotResponseDTO.ChatBotQA.class))), + } + ) + public ResponseEntity> getHistory( + @Valid @ModelAttribute ChatBotRequestDTO.ChatBotPagingRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + ChatBotResponseDTO.ChatBotFinalResponse history = chatbotService.getChatHistory(userDetails.getUserId(), + request); + return ResponseEntity.ok(ApiResponse.onSuccess(history)); + } +} From a636b07cb5741ef07ce11f2c55b8e3f52127395e Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Sun, 13 Jul 2025 21:59:58 +0900 Subject: [PATCH 152/339] =?UTF-8?q?[Feature]=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/web/controller/FragranceController.java | 4 ++-- src/main/resources/application-dev.yml | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java index a3db993..a9b96d0 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java @@ -61,7 +61,7 @@ public ResponseEntity> g */ @GetMapping("/allow/search") @Operation( - summary = "향수 키워드 검색 (무한 스크롤)", + summary = "향수 키워드 검색", description = "keyword 로 향수 이름을 검색하고, 페이징 처리된 결과를 반환합니다.", responses = { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "요청에 성공하였습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = FragranceResponseDTO.FragranceSearchResult.class))), @@ -133,7 +133,7 @@ public ResponseEntity Date: Sun, 13 Jul 2025 22:05:22 +0900 Subject: [PATCH 153/339] =?UTF-8?q?[Feature]=20dev=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EA=B3=B5=EB=B0=B1=20=EC=88=98=EC=A0=95=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index d5dd809..1fde84c 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -26,12 +26,12 @@ spring: port: 6379 cache: type: redis -jwt: - token: - secretKey: # secret key - expiration: - access: 7200000 # 2시간 - refresh: 1209600000 # 2주 + jwt: + token: + secretKey: # secret key + expiration: + access: 7200000 # 2시간 + refresh: 1209600000 # 2주 server: servlet: encoding: From 04eb9db37a1f452d05634349e9731313775f5e17 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Sun, 13 Jul 2025 22:43:04 +0900 Subject: [PATCH 154/339] =?UTF-8?q?[Fix]=20@PostConstruct=20=EB=A1=9C=20?= =?UTF-8?q?=EA=B3=A0=EC=A0=95=20=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=20?= =?UTF-8?q?=EB=A1=9C=EB=94=A9=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/service/chatbot/ChatbotServiceImpl.java | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/service/chatbot/ChatbotServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/chatbot/ChatbotServiceImpl.java index fc72d21..805a189 100644 --- a/src/main/java/PerfumeOnMe/spring/service/chatbot/ChatbotServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/chatbot/ChatbotServiceImpl.java @@ -25,6 +25,7 @@ import PerfumeOnMe.spring.web.dto.chatbot.ChatBotResponseDTO; import PerfumeOnMe.spring.web.dto.chatbot.ChatCompletionMessage; import PerfumeOnMe.spring.web.dto.chatbot.ChatCompletionRequest; +import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import reactor.core.publisher.Mono; @@ -40,6 +41,13 @@ public class ChatbotServiceImpl implements ChatbotService { @Value("${openai.model}") private String model; // OpenAI 모델 이름 - gpt-3.5-turbo + private String systemPrompt; // ← 캐시된 프롬프트 + + @PostConstruct + public void init() { + this.systemPrompt = promptLoader.loadDefaultPrompt(); // 프롬프트 파일 로딩 + } + /** * userId: 현재 로그인한 사용자 ID * request: 사용자 질문이 담긴 DTO @@ -50,9 +58,6 @@ public Mono ask(Long userId, ChatBotRequestDTO.ChatBotQARequest request) if (request.getMessage() == null) { throw new GeneralException(ErrorStatus.REQUIRED_MESSAGES); } - - String systemPrompt = promptLoader.loadDefaultPrompt(); // 프롬프트 파일 로딩 - User user = userRepository.findById(userId) .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); From 1504e1ed4e10856df82a269d08b47d276a227525 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 14 Jul 2025 14:04:18 +0900 Subject: [PATCH 155/339] =?UTF-8?q?[Feature]=20OAuth,=20JSON=20Parsing=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=B6=94=EA=B0=80=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/apiPayload/code/status/ErrorStatus.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index 14f91de..2d8dddc 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -26,7 +26,7 @@ public enum ErrorStatus implements BaseErrorCode { // 토큰 에러 INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "TOKEN4001", "유효하지 않은 토큰입니다."), - REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "TOKEN4002", "해당 리프레시 토큰이 존재하지 않습니다."), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "TOKEN4002", "리프레시 토큰을 입력해야 합니다."), EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "TOKEN4003", "만료된 토큰입니다."), LOGOUT_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "TOKEN4004", "로그아웃한 액세스 토큰입니다."), MALFORMED_TOKEN(HttpStatus.UNAUTHORIZED, "TOKEN4005", "토큰 구조가 잘못됐습니다."), @@ -34,6 +34,12 @@ public enum ErrorStatus implements BaseErrorCode { INVALID_SIGNATURE(HttpStatus.UNAUTHORIZED, "TOKEN4007", "토큰의 서명이 잘못됐습니다."), TOKEN_NOT_FOUND(HttpStatus.BAD_REQUEST, "TOKEN4008", "토큰이 없습니다."), + // OAuth 에러 + UNSUPPORTED_SOCIAL(HttpStatus.BAD_REQUEST, "OAUTH4001", "지원하지 않는 소셜 로그인 방식입니다."), + + // JSON Parsing 에러 + PARSE_ERROR(HttpStatus.BAD_REQUEST, "OAUTH4002", "파싱 중 오류가 생겼습니다."), + // 데이터시트 에러 UNSUPPORTED_BRAND(HttpStatus.BAD_REQUEST, "DATA4001", "지원하지 않는 브랜드입니다."), UNSUPPORTED_TYPE(HttpStatus.BAD_REQUEST, "DATA4002", "지원하지 않는 향수타입입니다."), From d1ae83bfcb09f147b94f53574eac41e49fecb8aa Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 14 Jul 2025 14:06:44 +0900 Subject: [PATCH 156/339] =?UTF-8?q?[Fix]=20JWT=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=A0=9C=EC=99=B8=20=EA=B2=BD=EB=A1=9C=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/config/security/SecurityConfig.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java b/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java index 7c34d68..0b2f72e 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java @@ -29,8 +29,9 @@ public class SecurityConfig { // 인증 여부를 확인하지 않을 경로 지정 public static final String[] AUTH_WHITELIST = { "/v3/api-docs/**", "/swagger-resources/**", "/swagger-ui.html", "/swagger-ui/**", - "/swagger/**", "/users/signup", "/auth/login", "/auth/social/kakao", "/users/reissue", - "/health", "/fragrances/allow/**" + "/swagger/**", "/users/signup", "/auth/login", "/auth/social/**", "/users/reissue", + "/health", "/fragrances/allow/**", "/auth/social/**", "/favicon.ico", "/images/**", + "/css/**", "/js/**", "/webjars/**" }; private final JwtAuthenticationFilter JwtAuthenticationFilter; private final JwtExceptionHandlerFilter JwtExceptionHandlerFilter; @@ -71,7 +72,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); - config.setAllowedOrigins(List.of("*")); // 변경 예정 + config.setAllowedOrigins(List.of("*")); // spring: [localhost:8080, localhost:5000], localhost:8081 config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedHeaders(List.of("Authorization", "Refresh-Token", "Content-Type")); config.setAllowCredentials(false); // origin 바꾸면 true로 설정 From 5125ff7725b10ab84ad8c4b8a4cc0cb9bdfd93f4 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 14 Jul 2025 14:08:16 +0900 Subject: [PATCH 157/339] =?UTF-8?q?[Fix]=20JWT=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=EC=97=90=20Social=20=EC=A0=95=EB=B3=B4=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/security/auth/converter/AuthConverter.java | 4 +++- .../config/security/auth/dto/AuthResponseDTO.java | 2 ++ .../security/auth/filter/JwtAuthenticationFilter.java | 3 ++- .../config/security/auth/filter/JwtLoginFilter.java | 3 ++- .../auth/provider/JwtAuthenticationProvider.java | 3 ++- .../security/auth/token/JwtAuthenticationToken.java | 10 ++++++++-- .../security/auth/userDetails/CustomUserDetails.java | 6 ++++++ .../auth/userDetails/CustomUserDetailsService.java | 2 +- 8 files changed, 26 insertions(+), 7 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/converter/AuthConverter.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/converter/AuthConverter.java index 066790f..c610461 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/converter/AuthConverter.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/converter/AuthConverter.java @@ -1,13 +1,15 @@ package PerfumeOnMe.spring.config.security.auth.converter; import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.domain.enums.Social; public class AuthConverter { - public static AuthResponseDTO.LoginResult toLoginResult(String refreshToken, Long userId) { + public static AuthResponseDTO.LoginResult toLoginResult(String refreshToken, Long userId, Social social) { return AuthResponseDTO.LoginResult.builder() .refreshToken(refreshToken) .userId(userId) + .social(social) .build(); } diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthResponseDTO.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthResponseDTO.java index 815a780..73236c3 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthResponseDTO.java @@ -1,5 +1,6 @@ package PerfumeOnMe.spring.config.security.auth.dto; +import PerfumeOnMe.spring.domain.enums.Social; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -14,5 +15,6 @@ public class AuthResponseDTO { public static class LoginResult { private String refreshToken; private Long userId; + private Social social; } } diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtAuthenticationFilter.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtAuthenticationFilter.java index b80396a..8e1b52b 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtAuthenticationFilter.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtAuthenticationFilter.java @@ -14,6 +14,7 @@ import PerfumeOnMe.spring.config.security.auth.manager.LogoutAccessTokenManager; import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; import PerfumeOnMe.spring.config.security.auth.token.JwtAuthenticationToken; +import PerfumeOnMe.spring.domain.enums.Social; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -46,7 +47,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse throw new GeneralException(ErrorStatus.LOGOUT_ACCESS_TOKEN); } - JwtAuthenticationToken authRequest = new JwtAuthenticationToken(loginId, accessToken); + JwtAuthenticationToken authRequest = new JwtAuthenticationToken(loginId, accessToken, Social.LOCAL); Authentication authResult = authenticationManager.authenticate(authRequest); SecurityContextHolder.getContext().setAuthentication(authResult); } diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java index dda1f07..e0d84aa 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java @@ -27,6 +27,7 @@ import PerfumeOnMe.spring.config.security.auth.manager.RefreshTokenManager; import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; +import PerfumeOnMe.spring.domain.enums.Social; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -86,7 +87,7 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR // 토큰 생성 및 DTO에 담기 String accessToken = jwtTokenProvider.createAccessToken(authResult); String refreshToken = jwtTokenProvider.createRefreshToken(authResult); - AuthResponseDTO.LoginResult loginResultDTO = AuthConverter.toLoginResult(refreshToken, userId); + AuthResponseDTO.LoginResult loginResultDTO = AuthConverter.toLoginResult(refreshToken, userId, Social.LOCAL); // 리프레시 토큰을 Redis에 저장 refreshTokenManager.saveRefreshToken(loginId, refreshToken); diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtAuthenticationProvider.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtAuthenticationProvider.java index 069381e..2315a59 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtAuthenticationProvider.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtAuthenticationProvider.java @@ -8,6 +8,7 @@ import org.springframework.stereotype.Component; import PerfumeOnMe.spring.config.security.auth.token.JwtAuthenticationToken; +import PerfumeOnMe.spring.domain.enums.Social; import lombok.RequiredArgsConstructor; /* @@ -29,7 +30,7 @@ public Authentication authenticate(Authentication authentication) throws Authent UserDetails userDetails = userDetailsService.loadUserByUsername(loginId); - return new JwtAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + return new JwtAuthenticationToken(userDetails, null, userDetails.getAuthorities(), Social.LOCAL); } @Override diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/token/JwtAuthenticationToken.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/token/JwtAuthenticationToken.java index bbbc84a..d74ccb1 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/token/JwtAuthenticationToken.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/token/JwtAuthenticationToken.java @@ -6,6 +6,7 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; +import PerfumeOnMe.spring.domain.enums.Social; import lombok.Getter; /* @@ -16,19 +17,24 @@ public class JwtAuthenticationToken extends UsernamePasswordAuthenticationToken private final Object principal; private final Object credentials; + private final Social social; - public JwtAuthenticationToken(Object principal, Object credentials) { + // 인증 전 + public JwtAuthenticationToken(Object principal, Object credentials, Social social) { super(principal, credentials); this.principal = principal; this.credentials = credentials; + this.social = social; } + // 인증 후 // GrantedAuthority를 포함한 생성자를 만들어야 신뢰할 수 있는, 인증된 토큰이 됨 public JwtAuthenticationToken(UserDetails userDetails, Object o, - Collection authorities) { + Collection authorities, Social social) { super(userDetails, null, authorities); this.principal = userDetails; this.credentials = null; + this.social = social; } @Override diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/userDetails/CustomUserDetails.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/userDetails/CustomUserDetails.java index b5222f6..8612049 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/userDetails/CustomUserDetails.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/userDetails/CustomUserDetails.java @@ -7,6 +7,7 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; +import PerfumeOnMe.spring.domain.enums.Social; import lombok.RequiredArgsConstructor; /* @@ -19,6 +20,7 @@ public class CustomUserDetails implements UserDetails { private final String name; private final String loginId; private final String password; + private final Social social; @Override public boolean isAccountNonExpired() { @@ -62,4 +64,8 @@ public Long getUserId() { public String getName() { return name; } + + public Social getSocial() { + return social; + } } diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/userDetails/CustomUserDetailsService.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/userDetails/CustomUserDetailsService.java index 334d64d..d729c37 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/userDetails/CustomUserDetailsService.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/userDetails/CustomUserDetailsService.java @@ -24,6 +24,6 @@ public UserDetails loadUserByUsername(String loginId) throws UsernameNotFoundExc .orElseThrow(() -> new UsernameNotFoundException("해당 사용자를 찾을 수 없습니다.")); return new CustomUserDetails( - user.getId(), user.getName(), user.getLoginId(), user.getPassword()); + user.getId(), user.getName(), user.getLoginId(), user.getPassword(), user.getSocial()); } } From bcdd11fd2e834e369e65b6fcd623dfabbb85231d Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 14 Jul 2025 14:11:43 +0900 Subject: [PATCH 158/339] =?UTF-8?q?[Fix]=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=8B=9C=20=ED=86=A0=ED=81=B0=20null=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20JWT=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=EC=97=90=20Social=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/service/user/UserServiceImpl.java | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java index 190337c..61d92a7 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java @@ -19,6 +19,7 @@ import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; import PerfumeOnMe.spring.converter.UserConverter; import PerfumeOnMe.spring.domain.User; +import PerfumeOnMe.spring.domain.enums.Social; import PerfumeOnMe.spring.repository.user.UserRepository; import PerfumeOnMe.spring.web.dto.user.UserRequestDTO; import PerfumeOnMe.spring.web.dto.user.UserResponseDTO; @@ -68,16 +69,22 @@ public UserResponseDTO.SignupResult signup(UserRequestDTO.Signup request) { @Override public AuthResponseDTO.LoginResult reissue(String reqRefreshToken, HttpServletResponse response) { + if (reqRefreshToken == null || reqRefreshToken.isBlank()) { + throw new GeneralException(ErrorStatus.REFRESH_TOKEN_NOT_FOUND); + } + // 리프레시 토큰에서 Subject 추출 String loginId = jwtTokenProvider.getSubject(reqRefreshToken); // 토큰 생성 및 DTO에 담기 UserDetails userDetails = userDetailsService.loadUserByUsername(loginId); - JwtAuthenticationToken request = new JwtAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + Social social = ((CustomUserDetails)userDetails).getSocial(); + JwtAuthenticationToken request = new JwtAuthenticationToken( + userDetails, null, userDetails.getAuthorities(), social); String accessToken = jwtTokenProvider.createAccessToken(request); String refreshToken = jwtTokenProvider.createRefreshToken(request); Long userId = ((CustomUserDetails)userDetails).getUserId(); - AuthResponseDTO.LoginResult loginResultDTO = AuthConverter.toLoginResult(refreshToken, userId); + AuthResponseDTO.LoginResult loginResultDTO = AuthConverter.toLoginResult(refreshToken, userId, social); // 새로 발급한 리프레시 토큰을 Redis에 저장 - 덮어씌우기 refreshTokenManager.saveRefreshToken(loginId, refreshToken); From 3ae18bf35686a07245ae0fbb82f97be362df7c2e Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 14 Jul 2025 14:17:10 +0900 Subject: [PATCH 159/339] =?UTF-8?q?[Feature]=20Kakao=20=EC=86=8C=EC=85=9C?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20DTO=20=EC=B6=94=EA=B0=80=20(#2?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/oauth/dto/KakaoProperties.java | 26 ++++++++ .../security/oauth/dto/KakaoResponseDTO.java | 61 +++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/oauth/dto/KakaoProperties.java create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/oauth/dto/KakaoResponseDTO.java diff --git a/src/main/java/PerfumeOnMe/spring/config/security/oauth/dto/KakaoProperties.java b/src/main/java/PerfumeOnMe/spring/config/security/oauth/dto/KakaoProperties.java new file mode 100644 index 0000000..056932d --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/oauth/dto/KakaoProperties.java @@ -0,0 +1,26 @@ +package PerfumeOnMe.spring.config.security.oauth.dto; + +import java.util.List; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import lombok.Getter; +import lombok.Setter; + +@Component +@Getter +@Setter +@ConfigurationProperties("spring.security.oauth2.kakao") +public class KakaoProperties { + + private String clientId; + private String clientSecret; + private String redirectUri; + private String authorizationUri; + private String tokenUri; + private String userInfoUri; + private String userNameAttribute; + private List scopes; + private String authorizationGrantType; +} diff --git a/src/main/java/PerfumeOnMe/spring/config/security/oauth/dto/KakaoResponseDTO.java b/src/main/java/PerfumeOnMe/spring/config/security/oauth/dto/KakaoResponseDTO.java new file mode 100644 index 0000000..62dd35f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/oauth/dto/KakaoResponseDTO.java @@ -0,0 +1,61 @@ +package PerfumeOnMe.spring.config.security.oauth.dto; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class KakaoResponseDTO { + + // 인가 코드로 받급받는 카카오 토큰 + @Getter + @NoArgsConstructor + public static class Token { + private String token_type; + private String access_token; + private String id_token; + private Integer expires_in; + private String refresh_token; + private Integer refresh_token_expires_in; + private String scope; + } + + // 카카오 토큰으로 가져온 사용자 정보 + @Getter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class UserInfo { + private Long id; + private LocalDateTime connected_at; + private KakaoAccount kakao_account; + + @Getter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class KakaoAccount { + private Boolean profile_needs_agreement; + private Boolean profile_nickname_needs_agreement; + private Boolean profile_image_needs_agreement; + private Profile profile; + private Boolean name_needs_agreement; + private String name; + private Boolean email_needs_agreement; + private Boolean is_email_valid; + private Boolean is_email_verified; + private String email; + + @Getter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class Profile { + private String nickname; + private String thumbnail_image_url; + private String profile_image_url; + private Boolean is_default_image; + private Boolean is_default_nickname; + } + } + } +} From 821917b41a76c95f71154396ad76e83c34913aa9 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 14 Jul 2025 14:20:08 +0900 Subject: [PATCH 160/339] =?UTF-8?q?[Feature]=20=EB=8B=A4=ED=98=95=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20=ED=99=9C=EC=9A=A9=ED=95=B4=20=EC=B9=B4=EC=B9=B4?= =?UTF-8?q?=EC=98=A4=20=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?Service=20=EA=B5=AC=ED=98=84=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/oauth/service/KakaoService.java | 90 +++++++++++++++++++ .../security/oauth/service/OAuthService.java | 14 +++ .../oauth/service/OAuthServiceFactory.java | 29 ++++++ 3 files changed, 133 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/oauth/service/KakaoService.java create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/oauth/service/OAuthService.java create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/oauth/service/OAuthServiceFactory.java diff --git a/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/KakaoService.java b/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/KakaoService.java new file mode 100644 index 0000000..080fc59 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/KakaoService.java @@ -0,0 +1,90 @@ +package PerfumeOnMe.spring.config.security.oauth.service; + +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.stereotype.Service; + +import PerfumeOnMe.spring.config.security.auth.converter.AuthConverter; +import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.config.security.auth.manager.LogoutAccessTokenManager; +import PerfumeOnMe.spring.config.security.auth.manager.RefreshTokenManager; +import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; +import PerfumeOnMe.spring.config.security.auth.token.JwtAuthenticationToken; +import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; +import PerfumeOnMe.spring.config.security.oauth.converter.OAuthConverter; +import PerfumeOnMe.spring.config.security.oauth.dto.KakaoResponseDTO; +import PerfumeOnMe.spring.config.security.oauth.util.KakaoUtil; +import PerfumeOnMe.spring.domain.User; +import PerfumeOnMe.spring.domain.enums.Social; +import PerfumeOnMe.spring.repository.user.UserRepository; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class KakaoService implements OAuthService { + + private final KakaoUtil kakaoUtil; + private final UserRepository userRepository; + private final JwtTokenProvider jwtTokenProvider; + private final UserDetailsService userDetailsService; + private final RefreshTokenManager refreshTokenManager; + private final LogoutAccessTokenManager logoutAccessTokenManager; + + @Override + public AuthResponseDTO.LoginResult oAuthLogin(String code, HttpServletResponse response) { + + // 카카오 토큰 요청 + KakaoResponseDTO.Token token = kakaoUtil.requestToken(code); + + // 카카오 사용자 정보 요청 + KakaoResponseDTO.UserInfo userInfo = kakaoUtil.requestUserInfo(token.getAccess_token()); + String name = (userInfo.getKakao_account().getName()); + String email = userInfo.getKakao_account().getEmail(); + String nickname = userInfo.getKakao_account().getProfile().getNickname(); + String imageUrl = userInfo.getKakao_account().getProfile().getProfile_image_url(); + + // 이미 가입한 사용자라면 꺼내고, 아니라면 회원가입 진행 + User user = userRepository.findUserByLoginId(email).orElseGet(() -> { + User newUser = OAuthConverter.toSignupUser( + Social.KAKAO, "kakao" + email, name, "password", imageUrl, nickname); + return userRepository.save(newUser); + }); + + // 사용자의 로그아웃 액세스 토큰이 존재하는 경우 삭제 + if (logoutAccessTokenManager.findLogoutAccessToken(user.getLoginId())) { + logoutAccessTokenManager.deleteLogoutAccessToken(user.getLoginId()); + } + + // 사용자 JWT 인증 및 토큰 발급 + UserDetails userDetails = userDetailsService.loadUserByUsername(user.getLoginId()); + Long userId = ((CustomUserDetails)userDetails).getUserId(); + JwtAuthenticationToken request = new JwtAuthenticationToken( + userDetails, null, userDetails.getAuthorities(), Social.KAKAO); + String accessToken = jwtTokenProvider.createAccessToken(request); + String refreshToken = jwtTokenProvider.createRefreshToken(request); + AuthResponseDTO.LoginResult loginResultDTO = AuthConverter.toLoginResult(refreshToken, userId, Social.KAKAO); + + // 새로 발급한 리프레시 토큰을 Redis에 저장 + refreshTokenManager.saveRefreshToken(email, refreshToken); + + // 액세스 토큰 헤더 설정 및 응답 DTO 반환 + response.setCharacterEncoding("UTF-8"); + response.setContentType("application/json"); + response.setStatus(HttpServletResponse.SC_OK); + response.setHeader("Authorization", "Bearer " + accessToken); + + // SecurityContextHolder에 인증 설정 + SecurityContextHolder.getContext().setAuthentication(request); + + return loginResultDTO; + } + + @Override + public Social getProvider() { + return Social.KAKAO; + } +} diff --git a/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/OAuthService.java b/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/OAuthService.java new file mode 100644 index 0000000..7f29355 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/OAuthService.java @@ -0,0 +1,14 @@ +package PerfumeOnMe.spring.config.security.oauth.service; + +import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.domain.enums.Social; +import jakarta.servlet.http.HttpServletResponse; + +/* +다양한 소셜 로그인 방식을 위한 인터페이스 + */ +public interface OAuthService { + AuthResponseDTO.LoginResult oAuthLogin(String code, HttpServletResponse response); + + Social getProvider(); +} diff --git a/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/OAuthServiceFactory.java b/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/OAuthServiceFactory.java new file mode 100644 index 0000000..7cf9f81 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/OAuthServiceFactory.java @@ -0,0 +1,29 @@ +package PerfumeOnMe.spring.config.security.oauth.service; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Component; + +import PerfumeOnMe.spring.domain.enums.Social; + +/* +Social과 OAuthService 구현체를 맵으로 저장 +-> Social에 맞는 OAuthService 구현체를 꺼내는 용도 + */ +@Component +public class OAuthServiceFactory { + + private final Map serviceMap; + + public OAuthServiceFactory(List serviceList) { + this.serviceMap = serviceList.stream() + .collect(Collectors.toMap(OAuthService::getProvider, Function.identity())); + } + + public OAuthService getOAuthService(Social social) { + return serviceMap.get(social); + } +} From c81db55aff4e766711561280d72cbbcb4a29b4df Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 14 Jul 2025 14:20:35 +0900 Subject: [PATCH 161/339] =?UTF-8?q?[Feature]=20=EB=AC=B8=EC=9E=90=EC=97=B4?= =?UTF-8?q?=EC=9D=84=20Social=EB=A1=9C=20=EB=B3=80=ED=99=98=ED=95=98?= =?UTF-8?q?=EB=8A=94=20Resolver=20=EA=B5=AC=ED=98=84=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oauth/util/OAuthProviderResolver.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/oauth/util/OAuthProviderResolver.java diff --git a/src/main/java/PerfumeOnMe/spring/config/security/oauth/util/OAuthProviderResolver.java b/src/main/java/PerfumeOnMe/spring/config/security/oauth/util/OAuthProviderResolver.java new file mode 100644 index 0000000..97c4e45 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/oauth/util/OAuthProviderResolver.java @@ -0,0 +1,20 @@ +package PerfumeOnMe.spring.config.security.oauth.util; + +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.domain.enums.Social; + +/* +String으로 받아온 provider(ex. kakao)를 Social Enum으로 반환 + */ +public class OAuthProviderResolver { + + public static Social resolve(String provider) { + try { + return Social.valueOf(provider.toUpperCase()); + } catch (IllegalArgumentException | NullPointerException e) { + throw new GeneralException(ErrorStatus.UNSUPPORTED_SOCIAL); + } + } + +} From 9d5c93f918e13bbbdce9d0ec69d95114ce6a1655 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 14 Jul 2025 14:21:09 +0900 Subject: [PATCH 162/339] =?UTF-8?q?[Feature]=20=EC=B9=B4=EC=B9=B4=EC=98=A4?= =?UTF-8?q?=20=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20Converter?= =?UTF-8?q?,=20Controller=20=EA=B5=AC=ED=98=84=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../oauth/controller/OAuthController.java | 55 +++++++++++++++++++ .../oauth/converter/OAuthConverter.java | 20 +++++++ 2 files changed, 75 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/oauth/controller/OAuthController.java create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/oauth/converter/OAuthConverter.java diff --git a/src/main/java/PerfumeOnMe/spring/config/security/oauth/controller/OAuthController.java b/src/main/java/PerfumeOnMe/spring/config/security/oauth/controller/OAuthController.java new file mode 100644 index 0000000..f1bce0d --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/oauth/controller/OAuthController.java @@ -0,0 +1,55 @@ +package PerfumeOnMe.spring.config.security.oauth.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.config.security.oauth.service.OAuthService; +import PerfumeOnMe.spring.config.security.oauth.service.OAuthServiceFactory; +import PerfumeOnMe.spring.config.security.oauth.util.OAuthProviderResolver; +import PerfumeOnMe.spring.domain.enums.Social; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth/social") +public class OAuthController { + + private final OAuthServiceFactory serviceFactory; + + @GetMapping("/{provider}") + @Operation( + summary = "소셜 로그인 API", + description = "소셜 액세스 토큰을 발급하고, 해당 토큰으로 사용자 정보를 가져와 회원가입 및 로그인을 진행하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다."), + }, + parameters = { + @Parameter(name = "code", description = "인가 코드가 필요합니다."), + @Parameter(name = "provider", description = "예시: kakao") + } + ) + public ResponseEntity> oAuthLogin( + @RequestParam("code") String code, + @PathVariable("provider") String provider, + HttpServletResponse response) { + + // provider에 맞는 OAuthService 얻기 + Social social = OAuthProviderResolver.resolve(provider); + OAuthService oAuthService = serviceFactory.getOAuthService(social); + + // 결과 얻기 + AuthResponseDTO.LoginResult result = oAuthService.oAuthLogin(code, response); + + return ResponseEntity.ok().body(ApiResponse.onSuccess(result)); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/config/security/oauth/converter/OAuthConverter.java b/src/main/java/PerfumeOnMe/spring/config/security/oauth/converter/OAuthConverter.java new file mode 100644 index 0000000..690e52f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/oauth/converter/OAuthConverter.java @@ -0,0 +1,20 @@ +package PerfumeOnMe.spring.config.security.oauth.converter; + +import PerfumeOnMe.spring.domain.User; +import PerfumeOnMe.spring.domain.enums.Social; + +public class OAuthConverter { + + // 소셜 로그인 동의 후 회원가입 + public static User toSignupUser(Social social, String email, String name, + String password, String imageUri, String nickname) { + return User.builder() + .social(social) + .name(name) + .loginId(email) + .password(password) + .imageURL(imageUri) + .nickname(nickname) + .build(); + } +} From ac1bba87c6cc08ba40ed405c14361b194f81c25b Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 14 Jul 2025 14:23:17 +0900 Subject: [PATCH 163/339] =?UTF-8?q?[Feature]=20=EC=B9=B4=EC=B9=B4=EC=98=A4?= =?UTF-8?q?=20API=EC=99=80=20=ED=86=B5=EC=8B=A0=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EA=B5=AC=ED=98=84=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/oauth/util/KakaoClient.java | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/oauth/util/KakaoClient.java diff --git a/src/main/java/PerfumeOnMe/spring/config/security/oauth/util/KakaoClient.java b/src/main/java/PerfumeOnMe/spring/config/security/oauth/util/KakaoClient.java new file mode 100644 index 0000000..2bf2db6 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/oauth/util/KakaoClient.java @@ -0,0 +1,85 @@ +package PerfumeOnMe.spring.config.security.oauth.util; + +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.config.security.oauth.dto.KakaoProperties; +import PerfumeOnMe.spring.config.security.oauth.dto.KakaoResponseDTO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/* +Kakao API로 통신하는 클래스 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class KakaoClient { + + private final KakaoProperties kakaoProperties; + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + // 인가 코드로 카카오 토큰 요청 + public KakaoResponseDTO.Token requestToken(String code) { + + // HttpEntity 헤더 작성 + HttpHeaders headers = new HttpHeaders(); + headers.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8"); + + // HttpEntity 바디 작성 + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", kakaoProperties.getAuthorizationGrantType()); + params.add("client_id", kakaoProperties.getClientId()); + params.add("redirect_uri", kakaoProperties.getRedirectUri()); + params.add("code", code); + // params.add("client_secret", kakaoProperties.getClientSecret()); + + // HttpEntity 생성 + HttpEntity> request = new HttpEntity<>(params, headers); + + // 카카오 토큰 요청 및 반환 + try { + ResponseEntity response = restTemplate + .exchange(kakaoProperties.getTokenUri(), HttpMethod.POST, request, String.class); + KakaoResponseDTO.Token token = objectMapper.readValue(response.getBody(), KakaoResponseDTO.Token.class); + return token; + } catch (JsonProcessingException e) { + throw new GeneralException(ErrorStatus.PARSE_ERROR); + } + } + + // 카카오 토큰으로 사용자 정보 요청 + public KakaoResponseDTO.UserInfo requestUserInfo(String accessToken) { + + // HttpEntity 헤더 작성 + HttpHeaders headers = new HttpHeaders(); + headers.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8"); + headers.add("Authorization", "Bearer " + accessToken); + + // HttpEntity 생성 + HttpEntity request = new HttpEntity<>(headers); + + // 사용자 정보 요청 및 반환 + try { + ResponseEntity response = restTemplate + .exchange(kakaoProperties.getUserInfoUri(), HttpMethod.GET, request, String.class); + KakaoResponseDTO.UserInfo userInfo = objectMapper.readValue(response.getBody(), + KakaoResponseDTO.UserInfo.class); + return userInfo; + } catch (JsonProcessingException e) { + throw new GeneralException(ErrorStatus.PARSE_ERROR); + } + } +} From 353d92d4ad13de76529e161e207b8fb313f17612 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 14 Jul 2025 14:23:50 +0900 Subject: [PATCH 164/339] =?UTF-8?q?[Fix]=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD=20=EC=82=AC=ED=95=AD=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20(#25)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/security/oauth/service/KakaoService.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/KakaoService.java b/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/KakaoService.java index 080fc59..38dda96 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/KakaoService.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/KakaoService.java @@ -14,7 +14,7 @@ import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; import PerfumeOnMe.spring.config.security.oauth.converter.OAuthConverter; import PerfumeOnMe.spring.config.security.oauth.dto.KakaoResponseDTO; -import PerfumeOnMe.spring.config.security.oauth.util.KakaoUtil; +import PerfumeOnMe.spring.config.security.oauth.util.KakaoClient; import PerfumeOnMe.spring.domain.User; import PerfumeOnMe.spring.domain.enums.Social; import PerfumeOnMe.spring.repository.user.UserRepository; @@ -27,7 +27,7 @@ @RequiredArgsConstructor public class KakaoService implements OAuthService { - private final KakaoUtil kakaoUtil; + private final KakaoClient kakaoClient; private final UserRepository userRepository; private final JwtTokenProvider jwtTokenProvider; private final UserDetailsService userDetailsService; @@ -38,10 +38,10 @@ public class KakaoService implements OAuthService { public AuthResponseDTO.LoginResult oAuthLogin(String code, HttpServletResponse response) { // 카카오 토큰 요청 - KakaoResponseDTO.Token token = kakaoUtil.requestToken(code); + KakaoResponseDTO.Token token = kakaoClient.requestToken(code); // 카카오 사용자 정보 요청 - KakaoResponseDTO.UserInfo userInfo = kakaoUtil.requestUserInfo(token.getAccess_token()); + KakaoResponseDTO.UserInfo userInfo = kakaoClient.requestUserInfo(token.getAccess_token()); String name = (userInfo.getKakao_account().getName()); String email = userInfo.getKakao_account().getEmail(); String nickname = userInfo.getKakao_account().getProfile().getNickname(); From 547ee66db273ad22b6958cf54a749e8e12c20af6 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Mon, 14 Jul 2025 14:23:58 +0900 Subject: [PATCH 165/339] =?UTF-8?q?[CI/CD]=20application-dev=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=EC=88=98=EC=A0=95(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 2acc93f..c6e5462 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -5,9 +5,6 @@ spring: import: - application-secret.yml datasource: - url: # RDS - username: # Username - password: # Password driver-class-name: com.mysql.cj.jdbc.Driver sql: init: @@ -23,9 +20,6 @@ spring: default_batch_fetch_size: 100 show-sql: true data: - redis: - host: # AWS Redis - port: 6379 cache: type: redis jwt: From 165c16d79db1e57b27ab5a347cd04efd2c036f8a Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 14 Jul 2025 14:41:15 +0900 Subject: [PATCH 166/339] =?UTF-8?q?[Fix]=20application-dev.yml=EC=97=90=20?= =?UTF-8?q?=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=84=A4=EC=A0=95=EA=B0=92=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index c6e5462..d8d2280 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -22,6 +22,22 @@ spring: data: cache: type: redis + security: + oauth2: + kakao: + client-id: ${KAKAO_REST_API_KEY} + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: "http://localhost:8080/auth/social/kakao" + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + scope: + - profile_nickname + - profile_image + - account_email + - name + authorization-grant-type: authorization_code jwt: token: secretKey: ${jwt.secret.secret_key} From 201f75e74ba5a9204b4fab3e8ea1611402828a53 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Mon, 14 Jul 2025 14:59:51 +0900 Subject: [PATCH 167/339] =?UTF-8?q?[CI/CD]=20application-secret=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=ED=9B=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev_deploy.yml | 21 ++++++++++++++------- src/main/resources/application-dev.yml | 10 +++++++--- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index 06d1bdf..7656888 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -16,12 +16,12 @@ jobs: with: java-version: '21' distribution: 'adopt' - - name: Copy Secret - env: - OCCUPY_SECRET: ${{ secrets.OCCUPY_SECRET }} - OCCUPY_SECRET_DIR: src/main/resources - OCCUPY_SECRET_DIR_FILE_NAME: application-secret.yml - run: echo $OCCUPY_SECRET | base64 --decode > $OCCUPY_SECRET_DIR/$OCCUPY_SECRET_DIR_FILE_NAME + # - name: Copy Secret + # env: + # OCCUPY_SECRET: ${{ secrets.OCCUPY_SECRET }} + # OCCUPY_SECRET_DIR: src/main/resources + # OCCUPY_SECRET_DIR_FILE_NAME: application-secret.yml + # run: echo $OCCUPY_SECRET | base64 --decode > $OCCUPY_SECRET_DIR/$OCCUPY_SECRET_DIR_FILE_NAME - name: gradlew mod modify run: chmod +x gradlew @@ -81,7 +81,14 @@ jobs: sudo docker rm perfumeonme || true sudo docker rmi chanee29/perfumeonme || true sudo docker pull chanee29/perfumeonme - sudo docker run -d -p 8080:8080 --name perfumeonme chanee29/perfumeonme + sudo docker run -d -p 8080:8080 --name perfumeonme \ + -e DB_URL=${{ secrets.ENV_DB_URL }} \ + -e DB_USERNAME=${{ secrets.ENV_DB_USERNAME }} \ + -e DB_PASSWORD=${{ secrets.ENV_DB_PASSWORD }} \ + -e REDIS_HOST=${{ secrets.ENV_REDIS_HOST }} \ + -e REDIS_PORT=${{ secrets.ENV_REDIS_PORT }} \ + -e JWT_SECRET=${{ secrets.ENV_JWT_SECRET }} \ + chanee29/perfumeonme - name: Remove GitHub IP FROM security group run: | diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index c6e5462..c21549e 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -2,9 +2,10 @@ spring: config: activate: on-profile: dev - import: - - application-secret.yml datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver sql: init: @@ -20,11 +21,14 @@ spring: default_batch_fetch_size: 100 show-sql: true data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT:6379} cache: type: redis jwt: token: - secretKey: ${jwt.secret.secret_key} + secretKey: ${JWT_SECRET} expiration: access: 7200000 # 2시간 refresh: 1209600000 # 2주 From 6da6fe4f258c8765c7752483645aa78b890a76a3 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Mon, 14 Jul 2025 15:12:36 +0900 Subject: [PATCH 168/339] =?UTF-8?q?[CI/CD]=20dev=5Fdeploy.yml=EC=97=90=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=EC=84=A4=EC=A0=95=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev_deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index 7656888..72f2dd9 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -82,6 +82,7 @@ jobs: sudo docker rmi chanee29/perfumeonme || true sudo docker pull chanee29/perfumeonme sudo docker run -d -p 8080:8080 --name perfumeonme \ + -e SPRING_PROFILES_ACTIVE=dev \ -e DB_URL=${{ secrets.ENV_DB_URL }} \ -e DB_USERNAME=${{ secrets.ENV_DB_USERNAME }} \ -e DB_PASSWORD=${{ secrets.ENV_DB_PASSWORD }} \ From 0af03ff724be85a9346d235f041d303eb5d804bc Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Mon, 14 Jul 2025 15:43:30 +0900 Subject: [PATCH 169/339] =?UTF-8?q?[CI/CD]=20application-dev.yml=20?= =?UTF-8?q?=ED=83=AD=20=EC=88=98=EC=A0=95(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index c21549e..d237507 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -26,12 +26,12 @@ spring: port: ${REDIS_PORT:6379} cache: type: redis - jwt: - token: - secretKey: ${JWT_SECRET} - expiration: - access: 7200000 # 2시간 - refresh: 1209600000 # 2주 +jwt: + token: + secretKey: ${JWT_SECRET:default_dummy} + expiration: + access: 7200000 # 2시간 + refresh: 1209600000 # 2주 server: servlet: encoding: From 5e6c506e07ec7b492f3912f4541346048d19a199 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 14 Jul 2025 16:21:02 +0900 Subject: [PATCH 170/339] =?UTF-8?q?[Fix]=20application-dev.yml=EC=97=90=20?= =?UTF-8?q?openai=20=EC=84=A4=EC=A0=95=EA=B0=92=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 7162132..47fbaa3 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -54,3 +54,6 @@ server: charset: UTF-8 force: true enabled: true +openai: + api-key: ${OPENAI_API_KEY} + model: gpt-4 \ No newline at end of file From 504b18f4b69df096ad6b7e62880ae44c96a2d003 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Mon, 14 Jul 2025 16:30:21 +0900 Subject: [PATCH 171/339] [CI/CD] ci/cd test(#41) --- src/main/resources/application-dev.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index d237507..fce4013 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -30,8 +30,8 @@ jwt: token: secretKey: ${JWT_SECRET:default_dummy} expiration: - access: 7200000 # 2시간 - refresh: 1209600000 # 2주 + access: 7200000 # 2H + refresh: 1209600000 # 2Week server: servlet: encoding: From a1288162bcd264c8179cbf37f66256432d8efc69 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Mon, 14 Jul 2025 20:55:22 +0900 Subject: [PATCH 172/339] =?UTF-8?q?[CI/CD]=20openai/kakao=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=8B=9C=ED=81=AC=EB=A6=BF=ED=82=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev_deploy.yml | 3 +++ src/main/resources/application-dev.yml | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index 72f2dd9..288d651 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -89,6 +89,9 @@ jobs: -e REDIS_HOST=${{ secrets.ENV_REDIS_HOST }} \ -e REDIS_PORT=${{ secrets.ENV_REDIS_PORT }} \ -e JWT_SECRET=${{ secrets.ENV_JWT_SECRET }} \ + -e OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }} \ + -e KAKAO_REST_API_KEY=${{ secrets.KAKAO_REST_API_KEY }} \ + -e KAKAO_CLIENT_SECRET=${{ secrets.KAKAO_CLIENT_SECRET }} \ chanee29/perfumeonme - name: Remove GitHub IP FROM security group diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index fce4013..c8d5176 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -26,6 +26,22 @@ spring: port: ${REDIS_PORT:6379} cache: type: redis + security: + oauth2: + kakao: + client-id: ${KAKAO_REST_API_KEY} + client-secret: ${KAKAO_CLIENT_SECRET} + redirect-uri: "http://localhost:8080/auth/social/kakao" + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id + scope: + - profile_nickname + - profile_image + - account_email + - name + authorization-grant-type: authorization_code jwt: token: secretKey: ${JWT_SECRET:default_dummy} @@ -38,3 +54,6 @@ server: charset: UTF-8 force: true enabled: true +openai: + api-key: ${OPENAI_API_KEY} + model: gpt-4 \ No newline at end of file From 0d5af89356dc132e2694dcc548d8bb7a67906b0a Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Mon, 14 Jul 2025 21:29:03 +0900 Subject: [PATCH 173/339] =?UTF-8?q?[CI/CD]=20application-dev.yml=EC=88=98?= =?UTF-8?q?=EC=A0=95(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index c8d5176..cd04dfe 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -24,8 +24,8 @@ spring: redis: host: ${REDIS_HOST} port: ${REDIS_PORT:6379} - cache: - type: redis + cache: + type: redis security: oauth2: kakao: From bf43a3cefebf0285935a886527ec7bffed1d5ccd Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Mon, 14 Jul 2025 22:10:00 +0900 Subject: [PATCH 174/339] =?UTF-8?q?[CI/CD]=20env=EB=B8=94=EB=A1=9D?= =?UTF-8?q?=EC=97=90=20secret=EC=A0=95=EC=9D=98=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EB=B3=80=EC=88=98=EB=AA=85=EC=9C=BC=EB=A1=9C=20=EC=B0=B8?= =?UTF-8?q?=EC=A1=B0(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev_deploy.yml | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index 288d651..989d421 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -70,6 +70,16 @@ jobs: - name: AWS EC2 Connection uses: appleboy/ssh-action@v0.1.6 + env: + ENV_DB_URL: ${{ secrets.ENV_DB_URL }} + ENV_DB_USERNAME: ${{ secrets.ENV_DB_USERNAME }} + ENV_DB_PASSWORD: ${{ secrets.ENV_DB_PASSWORD }} + ENV_REDIS_HOST: ${{ secrets.ENV_REDIS_HOST }} + ENV_REDIS_PORT: ${{ secrets.ENV_REDIS_PORT }} + ENV_JWT_SECRET: ${{ secrets.ENV_JWT_SECRET }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + KAKAO_REST_API_KEY: ${{ secrets.KAKAO_REST_API_KEY }} + KAKAO_CLIENT_SECRET: ${{ secrets.KAKAO_CLIENT_SECRET }} with: host: ${{ secrets.EC2_HOST }} username: ubuntu @@ -83,15 +93,15 @@ jobs: sudo docker pull chanee29/perfumeonme sudo docker run -d -p 8080:8080 --name perfumeonme \ -e SPRING_PROFILES_ACTIVE=dev \ - -e DB_URL=${{ secrets.ENV_DB_URL }} \ - -e DB_USERNAME=${{ secrets.ENV_DB_USERNAME }} \ - -e DB_PASSWORD=${{ secrets.ENV_DB_PASSWORD }} \ - -e REDIS_HOST=${{ secrets.ENV_REDIS_HOST }} \ - -e REDIS_PORT=${{ secrets.ENV_REDIS_PORT }} \ - -e JWT_SECRET=${{ secrets.ENV_JWT_SECRET }} \ - -e OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }} \ - -e KAKAO_REST_API_KEY=${{ secrets.KAKAO_REST_API_KEY }} \ - -e KAKAO_CLIENT_SECRET=${{ secrets.KAKAO_CLIENT_SECRET }} \ + -e DB_URL=$ENV_DB_URL \ + -e DB_USERNAME=$ENV_DB_USERNAME \ + -e DB_PASSWORD=$ENV_DB_PASSWORD \ + -e REDIS_HOST=$ENV_REDIS_HOST \ + -e REDIS_PORT=$ENV_REDIS_PORT \ + -e JWT_SECRET=$ENV_JWT_SECRET \ + -e OPENAI_API_KEY=$OPENAI_API_KEY \ + -e KAKAO_REST_API_KEY=$KAKAO_REST_API_KEY \ + -e KAKAO_CLIENT_SECRET=$KAKAO_CLIENT_SECRET \ chanee29/perfumeonme - name: Remove GitHub IP FROM security group From 872247c24607cbab2c5f4ee004a76c5ccfff68b4 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Mon, 14 Jul 2025 22:42:05 +0900 Subject: [PATCH 175/339] =?UTF-8?q?[CI/CD]=20=EC=98=A4=EB=A5=98=EB=B6=84?= =?UTF-8?q?=EC=84=9D=EC=9D=84=EC=9C=84=ED=95=B4=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=B6=94=EA=B0=80(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev_deploy.yml | 42 +++++++++++++++++--------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index 989d421..9b4cfdb 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -70,38 +70,40 @@ jobs: - name: AWS EC2 Connection uses: appleboy/ssh-action@v0.1.6 - env: - ENV_DB_URL: ${{ secrets.ENV_DB_URL }} - ENV_DB_USERNAME: ${{ secrets.ENV_DB_USERNAME }} - ENV_DB_PASSWORD: ${{ secrets.ENV_DB_PASSWORD }} - ENV_REDIS_HOST: ${{ secrets.ENV_REDIS_HOST }} - ENV_REDIS_PORT: ${{ secrets.ENV_REDIS_PORT }} - ENV_JWT_SECRET: ${{ secrets.ENV_JWT_SECRET }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - KAKAO_REST_API_KEY: ${{ secrets.KAKAO_REST_API_KEY }} - KAKAO_CLIENT_SECRET: ${{ secrets.KAKAO_CLIENT_SECRET }} with: host: ${{ secrets.EC2_HOST }} username: ubuntu key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} port: ${{ secrets.EC2_SSH_PORT }} - timeout: 60s + timeout: 300s script: | sudo docker stop perfumeonme || true sudo docker rm perfumeonme || true sudo docker rmi chanee29/perfumeonme || true sudo docker pull chanee29/perfumeonme + + echo "🚀 Starting new container with the following environment variables:" + echo "SPRING_PROFILES_ACTIVE=dev" + echo "DB_URL=${{ secrets.ENV_DB_URL }}" + echo "DB_USERNAME=${{ secrets.ENV_DB_USERNAME }}" + echo "REDIS_HOST=${{ secrets.ENV_REDIS_HOST }}" + echo "REDIS_PORT=${{ secrets.ENV_REDIS_PORT }}" + echo "JWT_SECRET=${{ secrets.ENV_JWT_SECRET }}" + echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}" + echo "KAKAO_REST_API_KEY=${{ secrets.KAKAO_REST_API_KEY }}" + echo "KAKAO_CLIENT_SECRET=${{ secrets.KAKAO_CLIENT_SECRET }}" + sudo docker run -d -p 8080:8080 --name perfumeonme \ -e SPRING_PROFILES_ACTIVE=dev \ - -e DB_URL=$ENV_DB_URL \ - -e DB_USERNAME=$ENV_DB_USERNAME \ - -e DB_PASSWORD=$ENV_DB_PASSWORD \ - -e REDIS_HOST=$ENV_REDIS_HOST \ - -e REDIS_PORT=$ENV_REDIS_PORT \ - -e JWT_SECRET=$ENV_JWT_SECRET \ - -e OPENAI_API_KEY=$OPENAI_API_KEY \ - -e KAKAO_REST_API_KEY=$KAKAO_REST_API_KEY \ - -e KAKAO_CLIENT_SECRET=$KAKAO_CLIENT_SECRET \ + -e DB_URL=${{ secrets.ENV_DB_URL }} \ + -e DB_USERNAME=${{ secrets.ENV_DB_USERNAME }} \ + -e DB_PASSWORD=${{ secrets.ENV_DB_PASSWORD }} \ + -e REDIS_HOST=${{ secrets.ENV_REDIS_HOST }} \ + -e REDIS_PORT=${{ secrets.ENV_REDIS_PORT }} \ + -e JWT_SECRET=${{ secrets.ENV_JWT_SECRET }} \ + -e OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }} \ + -e KAKAO_REST_API_KEY=${{ secrets.KAKAO_REST_API_KEY }} \ + -e KAKAO_CLIENT_SECRET=${{ secrets.KAKAO_CLIENT_SECRET }} \ chanee29/perfumeonme - name: Remove GitHub IP FROM security group From 10c32293c5e9cda0de9ee127de0fb533a3db9224 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Tue, 15 Jul 2025 09:07:54 +0900 Subject: [PATCH 176/339] =?UTF-8?q?[CI/CD]=20ddl-auto=EC=88=98=EC=A0=95(#4?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index cd04dfe..b49423f 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -12,7 +12,7 @@ spring: mode: never jpa: hibernate: - ddl-auto: create + ddl-auto: create #none으로 수정해야함 properties: hibernate: dialect: org.hibernate.dialect.MySQL8Dialect From c8ac57cff6fc92b43236b64bbd819ef94eca6c6a Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Tue, 15 Jul 2025 09:28:35 +0900 Subject: [PATCH 177/339] =?UTF-8?q?[CI/CD]=20DB=20URL=20secret=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=88=98=EC=A0=95=20=ED=9B=84=20=EB=B0=B0=ED=8F=AC?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index b49423f..cd04dfe 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -12,7 +12,7 @@ spring: mode: never jpa: hibernate: - ddl-auto: create #none으로 수정해야함 + ddl-auto: create properties: hibernate: dialect: org.hibernate.dialect.MySQL8Dialect From e2e9c2a6ccf70a1b6fc52f89cc3d6da411eb9c62 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Tue, 15 Jul 2025 09:37:06 +0900 Subject: [PATCH 178/339] =?UTF-8?q?[CI/CD]=20=EC=97=B0=EA=B2=B0=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=99=84=EB=A3=8C=20=ED=9B=84=20ddl-auto?= =?UTF-8?q?=20update=EC=88=98=EC=A0=95(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index cd04dfe..29c071d 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -12,7 +12,7 @@ spring: mode: never jpa: hibernate: - ddl-auto: create + ddl-auto: update # create X properties: hibernate: dialect: org.hibernate.dialect.MySQL8Dialect From 4ea805284f3f352242ba14fb86a32e84c2150374 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Wed, 16 Jul 2025 18:06:38 +0900 Subject: [PATCH 179/339] =?UTF-8?q?[Feature]=20=EC=9A=94=EC=B2=AD=20DTO=20?= =?UTF-8?q?=EB=B0=8F=20Validator=20=EA=B5=AC=ED=98=84=20(#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../validation/annotation/ExistUserAge.java | 23 +++++++++++++ .../annotation/ExistUserGender.java | 23 +++++++++++++ .../validation/annotation/ValidUserNote.java | 23 +++++++++++++ .../validator/ExistUserAgeValidator.java | 26 ++++++++++++++ .../validator/ExistUserGenderValidator.java | 26 ++++++++++++++ .../validator/ValidUserNoteValidator.java | 20 +++++++++++ .../spring/web/dto/user/UserRequestDTO.java | 34 +++++++++++++++++++ 7 files changed, 175 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUserAge.java create mode 100644 src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUserGender.java create mode 100644 src/main/java/PerfumeOnMe/spring/validation/annotation/ValidUserNote.java create mode 100644 src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserAgeValidator.java create mode 100644 src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserGenderValidator.java create mode 100644 src/main/java/PerfumeOnMe/spring/validation/validator/ValidUserNoteValidator.java diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUserAge.java b/src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUserAge.java new file mode 100644 index 0000000..4ef4bc6 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUserAge.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.validation.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import PerfumeOnMe.spring.validation.validator.ExistUserAgeValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = ExistUserAgeValidator.class) +@Documented +public @interface ExistUserAge { + String message() default "TEENAGER, TWENTIES, THIRTIES, FORTIES, NONE 중 하나만 입력할 수 있습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUserGender.java b/src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUserGender.java new file mode 100644 index 0000000..adeb1f4 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUserGender.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.validation.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import PerfumeOnMe.spring.validation.validator.ExistUserGenderValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = ExistUserGenderValidator.class) +@Documented +public @interface ExistUserGender { + String message() default "MALE, FEMALE, NONE 중 하나만 입력할 수 있습니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidUserNote.java b/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidUserNote.java new file mode 100644 index 0000000..8dc4e60 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidUserNote.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.validation.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import PerfumeOnMe.spring.validation.validator.ValidUserNoteValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = ValidUserNoteValidator.class) +@Documented +public @interface ValidUserNote { + String message() default "3개의 숫자가 입력돼야 합니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserAgeValidator.java b/src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserAgeValidator.java new file mode 100644 index 0000000..060bc1c --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserAgeValidator.java @@ -0,0 +1,26 @@ +package PerfumeOnMe.spring.validation.validator; + +import PerfumeOnMe.spring.domain.enums.Age; +import PerfumeOnMe.spring.validation.annotation.ExistUserAge; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class ExistUserAgeValidator implements ConstraintValidator { + + @Override + public void initialize(ExistUserAge constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext constraintValidatorContext) { + if (value == null || value.isEmpty()) + return false; + try { + Age.valueOf(value.toUpperCase()); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } +} diff --git a/src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserGenderValidator.java b/src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserGenderValidator.java new file mode 100644 index 0000000..d28d7a1 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserGenderValidator.java @@ -0,0 +1,26 @@ +package PerfumeOnMe.spring.validation.validator; + +import PerfumeOnMe.spring.domain.enums.UserGender; +import PerfumeOnMe.spring.validation.annotation.ExistUserGender; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class ExistUserGenderValidator implements ConstraintValidator { + + @Override + public void initialize(ExistUserGender constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null || value.isEmpty()) + return false; + try { + UserGender.valueOf(value.toUpperCase()); + return true; + } catch (IllegalArgumentException e) { + return false; + } + } +} diff --git a/src/main/java/PerfumeOnMe/spring/validation/validator/ValidUserNoteValidator.java b/src/main/java/PerfumeOnMe/spring/validation/validator/ValidUserNoteValidator.java new file mode 100644 index 0000000..d1ec296 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/validation/validator/ValidUserNoteValidator.java @@ -0,0 +1,20 @@ +package PerfumeOnMe.spring.validation.validator; + +import java.util.List; + +import PerfumeOnMe.spring.validation.annotation.ValidUserNote; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class ValidUserNoteValidator implements ConstraintValidator> { + + @Override + public void initialize(ValidUserNote constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } + + @Override + public boolean isValid(List longs, ConstraintValidatorContext constraintValidatorContext) { + return longs.size() == 3; + } +} diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java index ea94d4f..e0d100e 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java @@ -1,13 +1,20 @@ package PerfumeOnMe.spring.web.dto.user; +import java.util.List; + +import PerfumeOnMe.spring.validation.annotation.ExistUserAge; +import PerfumeOnMe.spring.validation.annotation.ExistUserGender; +import PerfumeOnMe.spring.validation.annotation.ValidUserNote; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import lombok.Getter; import lombok.NoArgsConstructor; public class UserRequestDTO { + // 회원가입 @Getter @NoArgsConstructor public static class Signup { @@ -33,4 +40,31 @@ public static class Signup { ) private String password; } + + // 온보딩 + @Getter + @NoArgsConstructor + public static class Onboarding { + @NotBlank + @Schema(description = "사용자가 입력한 닉네임", example = "리버") + @Pattern( + regexp = "^[가-힣a-zA-Z0-9]{2,10}$", + message = "닉네임은 한글, 영어 대소문자, 숫자만 허용되며, 공백 없이 2자 이상 10자 이하로 입력해주세요." + ) + private String nickname; + @Schema(description = "사용자가 설정한 사진 URL", example = "https://...") + private String imageURL; + @NotNull + @Schema(description = "사용자가 설정한 성별", example = "FEMALE") + @ExistUserGender + private String gender; + @NotNull + @Schema(description = "사용자가 설정한 연령대", example = "TWENTIES") + @ExistUserAge + private String age; + @NotNull + @Schema(description = "사용자가 설정한 선호하는 향", example = "[5,11,2]") + @ValidUserNote + private List noteCategoryId; + } } From ef87acbfa74fcd08158642fe6b9529c407b59a7a Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Wed, 16 Jul 2025 18:07:26 +0900 Subject: [PATCH 180/339] =?UTF-8?q?[Feature]=20=EC=97=B0=EA=B4=80=EA=B4=80?= =?UTF-8?q?=EA=B3=84=20=ED=8E=B8=EC=9D=98=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20(#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/PerfumeOnMe/spring/domain/User.java | 14 ++++++++++++++ .../spring/domain/mapping/UserNote.java | 16 ++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/domain/User.java b/src/main/java/PerfumeOnMe/spring/domain/User.java index 465726b..916fb83 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/User.java +++ b/src/main/java/PerfumeOnMe/spring/domain/User.java @@ -14,6 +14,7 @@ import PerfumeOnMe.spring.domain.mapping.UserFragrance; import PerfumeOnMe.spring.domain.mapping.UserNote; import PerfumeOnMe.spring.domain.mapping.UserTerms; +import PerfumeOnMe.spring.web.dto.user.UserRequestDTO; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -104,4 +105,17 @@ public class User extends BaseEntity { @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) @Builder.Default private List workshopList = new ArrayList<>(); + + public void onboarding(UserRequestDTO.Onboarding request) { + this.nickname = request.getNickname(); + this.imageURL = request.getImageURL(); + this.gender = UserGender.valueOf(request.getGender().toUpperCase()); + this.age = Age.valueOf(request.getAge().toUpperCase()); + } + + // 연관관계 편의 메서드 + public void addUserNote(UserNote userNote) { + this.userNoteList.add(userNote); + userNote.setUser(this); + } } diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/UserNote.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/UserNote.java index c9392f1..135c9b4 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/UserNote.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/UserNote.java @@ -41,4 +41,20 @@ public class UserNote extends BaseEntity { @JoinColumn(name = "note_id") private Note note; + // 연관관계 편의 메서드 + public void setUser(User user) { + if (this.user != null) { + user.getUserNoteList().remove(this); + } + this.user = user; + user.getUserNoteList().add(this); + } + + public void setNote(Note note) { + if (this.note != null) { + note.getUserNoteList().remove(this); + } + this.note = note; + note.getUserNoteList().add(this); + } } From 6e0de01d890f00d530f7befa2efdee38857bb298 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Wed, 16 Jul 2025 18:07:46 +0900 Subject: [PATCH 181/339] =?UTF-8?q?[Feature]=20ErrorStatus=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index 2ab2471..e417af2 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -23,6 +23,7 @@ public enum ErrorStatus implements BaseErrorCode { LOGIN_ID_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER4003", "해당 아이디를 가진 사용자가 존재하지 않습니다."), LOGIN_PARSING_FAIL(HttpStatus.BAD_REQUEST, "MEMBER4004", "로그인 DTO 변환을 실패했습니다."), LOGIN_UNKNOWN_ERROR(HttpStatus.BAD_REQUEST, "MEMBER4005", "로그인 중 알 수 없는 오류가 발생했습니다."), + NICKNAME_DUPLICATE(HttpStatus.BAD_REQUEST, "MEMBER4006", "이미 사용된 닉네임입니다."), // 토큰 에러 INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "TOKEN4001", "유효하지 않은 토큰입니다."), From 2965b42c18a615c7fe502b7d3fe2435e75a4e203 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Wed, 16 Jul 2025 18:08:05 +0900 Subject: [PATCH 182/339] =?UTF-8?q?[Feature]=20=EC=98=A8=EB=B3=B4=EB=94=A9?= =?UTF-8?q?=20API=20=EA=B5=AC=ED=98=84=20(#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/converter/UserNoteConverter.java | 16 +++++++++ .../repository/user/UserRepository.java | 2 ++ .../userNote/UserNoteRepository.java | 8 +++++ .../spring/service/user/UserService.java | 3 ++ .../spring/service/user/UserServiceImpl.java | 34 +++++++++++++++++++ .../spring/web/controller/UserController.java | 19 +++++++++++ 6 files changed, 82 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/converter/UserNoteConverter.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/userNote/UserNoteRepository.java diff --git a/src/main/java/PerfumeOnMe/spring/converter/UserNoteConverter.java b/src/main/java/PerfumeOnMe/spring/converter/UserNoteConverter.java new file mode 100644 index 0000000..a2654f8 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/converter/UserNoteConverter.java @@ -0,0 +1,16 @@ +package PerfumeOnMe.spring.converter; + +import PerfumeOnMe.spring.domain.Note; +import PerfumeOnMe.spring.domain.User; +import PerfumeOnMe.spring.domain.mapping.UserNote; + +public class UserNoteConverter { + + // 노트 + 사용자 = 사용자 노트 반환 + public static UserNote toUserNote(Note note, User user) { + return UserNote.builder() + .note(note) + .user(user) + .build(); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/user/UserRepository.java b/src/main/java/PerfumeOnMe/spring/repository/user/UserRepository.java index c3beb0f..5ceeb1e 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/user/UserRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/user/UserRepository.java @@ -11,4 +11,6 @@ public interface UserRepository extends JpaRepository, UserRepositor Optional findUserByLoginId(String loginId); Optional findById(Long id); + + Optional findUserByNickname(String nickname); } diff --git a/src/main/java/PerfumeOnMe/spring/repository/userNote/UserNoteRepository.java b/src/main/java/PerfumeOnMe/spring/repository/userNote/UserNoteRepository.java new file mode 100644 index 0000000..984ae08 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/userNote/UserNoteRepository.java @@ -0,0 +1,8 @@ +package PerfumeOnMe.spring.repository.userNote; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.domain.mapping.UserNote; + +public interface UserNoteRepository extends JpaRepository { +} diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserService.java b/src/main/java/PerfumeOnMe/spring/service/user/UserService.java index 79d5eaf..5a59c4a 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserService.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserService.java @@ -1,6 +1,7 @@ package PerfumeOnMe.spring.service.user; import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; import PerfumeOnMe.spring.web.dto.user.UserRequestDTO; import PerfumeOnMe.spring.web.dto.user.UserResponseDTO; import jakarta.servlet.http.HttpServletRequest; @@ -15,4 +16,6 @@ public interface UserService { String logout(HttpServletRequest request); void deleteUser(HttpServletRequest request); + + void onboarding(UserRequestDTO.Onboarding request, CustomUserDetails userDetails); } diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java index 61d92a7..2b2b986 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java @@ -18,9 +18,14 @@ import PerfumeOnMe.spring.config.security.auth.token.JwtAuthenticationToken; import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; import PerfumeOnMe.spring.converter.UserConverter; +import PerfumeOnMe.spring.converter.UserNoteConverter; +import PerfumeOnMe.spring.domain.Note; import PerfumeOnMe.spring.domain.User; import PerfumeOnMe.spring.domain.enums.Social; +import PerfumeOnMe.spring.domain.mapping.UserNote; +import PerfumeOnMe.spring.repository.note.NoteRepository; import PerfumeOnMe.spring.repository.user.UserRepository; +import PerfumeOnMe.spring.repository.userNote.UserNoteRepository; import PerfumeOnMe.spring.web.dto.user.UserRequestDTO; import PerfumeOnMe.spring.web.dto.user.UserResponseDTO; import jakarta.servlet.http.HttpServletRequest; @@ -38,6 +43,8 @@ public class UserServiceImpl implements UserService { private final RefreshTokenManager refreshTokenManager; private final LogoutAccessTokenManager logoutAccessTokenManager; private final UserDetailsService userDetailsService; + private final UserNoteRepository userNoteRepository; + private final NoteRepository noteRepository; // 사용자 회원가입 @Override @@ -128,4 +135,31 @@ public void deleteUser(HttpServletRequest request) { Optional findUser = userRepository.findUserByLoginId(loginId); findUser.ifPresent(userRepository::delete); } + + // 온보딩 + @Override + public void onboarding(UserRequestDTO.Onboarding request, CustomUserDetails userDetails) { + + // 닉네임 중복 검증 + if (userRepository.findUserByNickname(request.getNickname()).isPresent()) { + throw new GeneralException(ErrorStatus.NICKNAME_DUPLICATE); + } + + // 사용자 조회 + User findUser = userRepository.findUserByLoginId(userDetails.getUsername()) + .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + + // 온보딩 요청 정보 설정 - nickname, imageURL, gender, age + findUser.onboarding(request); + + // 온보딩 요청 정보 설정 - noteCategoryId + request.getNoteCategoryId().forEach(id -> { + Note note = noteRepository.findById(id) + .orElseThrow(() -> new GeneralException(ErrorStatus.INVALID_NOTE_ID)); + UserNote userNote = UserNoteConverter.toUserNote(note, findUser); + userNoteRepository.save(userNote); + findUser.addUserNote(userNote); // 양방향 연관관계만 설정 + note.getUserNoteList().add(userNote); // 양방향이지만 단방향처럼 사용 중이라 삭제해도 됨 + }); + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java index 703db8d..98aa618 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java @@ -2,6 +2,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -12,6 +13,7 @@ import PerfumeOnMe.spring.apiPayload.ApiResponse; import PerfumeOnMe.spring.apiPayload.code.status.SuccessStatus; import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; import PerfumeOnMe.spring.service.user.UserService; import PerfumeOnMe.spring.web.dto.user.UserRequestDTO; import PerfumeOnMe.spring.web.dto.user.UserResponseDTO; @@ -93,4 +95,21 @@ public ResponseEntity> deleteUser(HttpServletRequest request userService.deleteUser(request); return ResponseEntity.ok().body(ApiResponse.onSuccess(null)); } + + @PostMapping("/onboarding") + @Operation( + summary = "온보딩 API", + description = "사용자의 닉네임, 프로필 사진, 성별, 연령대, 선호하는 향 리스트를 입력받아 저장하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4006", description = "이미 사용된 닉네임입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FILTER4003", description = "유효하지 않은 노트 ID 입니다."), + } + ) + public ResponseEntity> onboarding(@Valid @RequestBody UserRequestDTO.Onboarding request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + userService.onboarding(request, userDetails); + return ResponseEntity.ok().body(ApiResponse.onSuccess(null)); + } } From 77747a1271af41a0003ab9b96d31c10871ddfda7 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Wed, 16 Jul 2025 18:08:21 +0900 Subject: [PATCH 183/339] =?UTF-8?q?[Fix]=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20Enum=20=EC=82=AD=EC=A0=9C=20(#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/PerfumeOnMe/spring/domain/enums/ChatSender.java | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 src/main/java/PerfumeOnMe/spring/domain/enums/ChatSender.java diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/ChatSender.java b/src/main/java/PerfumeOnMe/spring/domain/enums/ChatSender.java deleted file mode 100644 index 6ba7d1f..0000000 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/ChatSender.java +++ /dev/null @@ -1,5 +0,0 @@ -package PerfumeOnMe.spring.domain.enums; - -public enum ChatSender { - USER, AI -} From 118d1995ee8b5f47c76def350a00606456614991 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Wed, 16 Jul 2025 18:32:18 +0900 Subject: [PATCH 184/339] =?UTF-8?q?[Feature]=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=20=EC=84=A0=ED=98=B8=20=ED=96=A5=20=EC=88=98=EC=A0=95=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#48)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../userNote/UserNoteRepository.java | 3 ++ .../spring/service/user/UserService.java | 2 + .../spring/service/user/UserServiceImpl.java | 38 +++++++++++++++---- .../spring/web/controller/UserController.java | 17 +++++++++ .../spring/web/dto/user/UserRequestDTO.java | 10 +++++ 5 files changed, 62 insertions(+), 8 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/repository/userNote/UserNoteRepository.java b/src/main/java/PerfumeOnMe/spring/repository/userNote/UserNoteRepository.java index 984ae08..fef4d66 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/userNote/UserNoteRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/userNote/UserNoteRepository.java @@ -2,7 +2,10 @@ import org.springframework.data.jpa.repository.JpaRepository; +import PerfumeOnMe.spring.domain.User; import PerfumeOnMe.spring.domain.mapping.UserNote; public interface UserNoteRepository extends JpaRepository { + + void deleteAllByUser(User user); } diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserService.java b/src/main/java/PerfumeOnMe/spring/service/user/UserService.java index 5a59c4a..2cfe21c 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserService.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserService.java @@ -18,4 +18,6 @@ public interface UserService { void deleteUser(HttpServletRequest request); void onboarding(UserRequestDTO.Onboarding request, CustomUserDetails userDetails); + + void updateUserNote(UserRequestDTO.UserNoteUpdate request, CustomUserDetails userDetails); } diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java index 2b2b986..f7965c4 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java @@ -1,5 +1,6 @@ package PerfumeOnMe.spring.service.user; +import java.util.List; import java.util.Optional; import org.springframework.security.core.userdetails.UserDetails; @@ -136,6 +137,18 @@ public void deleteUser(HttpServletRequest request) { findUser.ifPresent(userRepository::delete); } + // 온보딩 요청 정보 설정 메서드 + public void saveUserNote(User user, List noteCategoryIdList) { + noteCategoryIdList.forEach(noteCategoryId -> { + Note note = noteRepository.findById(noteCategoryId) + .orElseThrow(() -> new GeneralException(ErrorStatus.INVALID_NOTE_ID)); + UserNote userNote = UserNoteConverter.toUserNote(note, user); + userNoteRepository.save(userNote); + user.addUserNote(userNote); // 양방향 연관관계만 설정 + note.getUserNoteList().add(userNote); // 양방향이지만 단방향처럼 사용 중이라 삭제해도 됨 + }); + } + // 온보딩 @Override public void onboarding(UserRequestDTO.Onboarding request, CustomUserDetails userDetails) { @@ -153,13 +166,22 @@ public void onboarding(UserRequestDTO.Onboarding request, CustomUserDetails user findUser.onboarding(request); // 온보딩 요청 정보 설정 - noteCategoryId - request.getNoteCategoryId().forEach(id -> { - Note note = noteRepository.findById(id) - .orElseThrow(() -> new GeneralException(ErrorStatus.INVALID_NOTE_ID)); - UserNote userNote = UserNoteConverter.toUserNote(note, findUser); - userNoteRepository.save(userNote); - findUser.addUserNote(userNote); // 양방향 연관관계만 설정 - note.getUserNoteList().add(userNote); // 양방향이지만 단방향처럼 사용 중이라 삭제해도 됨 - }); + saveUserNote(findUser, request.getNoteCategoryId()); + } + + // 사용자 선호 향 수정 + @Override + public void updateUserNote(UserRequestDTO.UserNoteUpdate request, CustomUserDetails userDetails) { + + // 사용자 조회 + User findUser = userRepository.findUserByLoginId(userDetails.getUsername()) + .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + + // 사용자 선호 노트 정보 삭제 + userNoteRepository.deleteAllByUser(findUser); + findUser.getUserNoteList().clear(); + + // 온보딩 요청 정보 설정 - noteCategoryId + saveUserNote(findUser, request.getNoteCategoryId()); } } diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java index 98aa618..ab67395 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java @@ -4,6 +4,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; @@ -112,4 +113,20 @@ public ResponseEntity> onboarding(@Valid @RequestBody UserRe userService.onboarding(request, userDetails); return ResponseEntity.ok().body(ApiResponse.onSuccess(null)); } + + @PatchMapping("/notes") + @Operation( + summary = "선호 향 수정 API", + description = "사용자의 선호하는 향 리스트를 입력받아 수정하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FILTER4003", description = "유효하지 않은 노트 ID 입니다."), + } + ) + public ResponseEntity> updateUserNote(@Valid @RequestBody UserRequestDTO.UserNoteUpdate request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + userService.updateUserNote(request, userDetails); + return ResponseEntity.ok().body(ApiResponse.onSuccess(null)); + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java index e0d100e..da88bc9 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java @@ -67,4 +67,14 @@ public static class Onboarding { @ValidUserNote private List noteCategoryId; } + + // 사용자 선호 향 수정 + @Getter + @NoArgsConstructor + public static class UserNoteUpdate { + @NotNull + @Schema(description = "사용자가 설정한 선호하는 향", example = "[5,11,2]") + @ValidUserNote + private List noteCategoryId; + } } From ec403715317de68cabee39b9ebb9b695d4c7a931 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Wed, 16 Jul 2025 19:08:17 +0900 Subject: [PATCH 185/339] =?UTF-8?q?[Fix]=20UserNoteConverter=EC=97=90?= =?UTF-8?q?=EC=84=9C=20user=EB=A5=BC=20=EC=84=A4=EC=A0=95=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/PerfumeOnMe/spring/converter/UserNoteConverter.java | 4 +--- .../java/PerfumeOnMe/spring/service/user/UserServiceImpl.java | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/converter/UserNoteConverter.java b/src/main/java/PerfumeOnMe/spring/converter/UserNoteConverter.java index a2654f8..e35f84d 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/UserNoteConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/UserNoteConverter.java @@ -1,16 +1,14 @@ package PerfumeOnMe.spring.converter; import PerfumeOnMe.spring.domain.Note; -import PerfumeOnMe.spring.domain.User; import PerfumeOnMe.spring.domain.mapping.UserNote; public class UserNoteConverter { // 노트 + 사용자 = 사용자 노트 반환 - public static UserNote toUserNote(Note note, User user) { + public static UserNote toUserNote(Note note) { return UserNote.builder() .note(note) - .user(user) .build(); } } diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java index f7965c4..62649ec 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java @@ -142,7 +142,7 @@ public void saveUserNote(User user, List noteCategoryIdList) { noteCategoryIdList.forEach(noteCategoryId -> { Note note = noteRepository.findById(noteCategoryId) .orElseThrow(() -> new GeneralException(ErrorStatus.INVALID_NOTE_ID)); - UserNote userNote = UserNoteConverter.toUserNote(note, user); + UserNote userNote = UserNoteConverter.toUserNote(note); userNoteRepository.save(userNote); user.addUserNote(userNote); // 양방향 연관관계만 설정 note.getUserNoteList().add(userNote); // 양방향이지만 단방향처럼 사용 중이라 삭제해도 됨 From 8fa30c67fedd4945c584dc0b44592e58cd86af82 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Wed, 16 Jul 2025 19:13:23 +0900 Subject: [PATCH 186/339] =?UTF-8?q?[Fix]=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/PerfumeOnMe/spring/service/user/UserServiceImpl.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java index 62649ec..9034ecc 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java @@ -145,7 +145,6 @@ public void saveUserNote(User user, List noteCategoryIdList) { UserNote userNote = UserNoteConverter.toUserNote(note); userNoteRepository.save(userNote); user.addUserNote(userNote); // 양방향 연관관계만 설정 - note.getUserNoteList().add(userNote); // 양방향이지만 단방향처럼 사용 중이라 삭제해도 됨 }); } From e52117df9b7f952e48889088c685502c31f27c1a Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Wed, 16 Jul 2025 20:51:59 +0900 Subject: [PATCH 187/339] =?UTF-8?q?[Bug]=20GitHUb=20PR=20=EB=8F=99?= =?UTF-8?q?=EC=9E=91=20=ED=99=95=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index e417af2..42596af 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -20,7 +20,7 @@ public enum ErrorStatus implements BaseErrorCode { // 사용자 에러 LOGIN_ID_DUPLICATE(HttpStatus.BAD_REQUEST, "MEMBER4001", "이미 사용된 아이디입니다."), PASSWORD_NOT_MATCH(HttpStatus.BAD_REQUEST, "MEMBER4002", "비밀번호가 일치하지 않습니다."), - LOGIN_ID_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER4003", "해당 아이디를 가진 사용자가 존재하지 않습니다."), + LOGIN_ID_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER4003", "해당 로그인 아이디를 가진 사용자가 존재하지 않습니다."), LOGIN_PARSING_FAIL(HttpStatus.BAD_REQUEST, "MEMBER4004", "로그인 DTO 변환을 실패했습니다."), LOGIN_UNKNOWN_ERROR(HttpStatus.BAD_REQUEST, "MEMBER4005", "로그인 중 알 수 없는 오류가 발생했습니다."), NICKNAME_DUPLICATE(HttpStatus.BAD_REQUEST, "MEMBER4006", "이미 사용된 닉네임입니다."), From f6e6ecbe4b78b02e36219382b97b1e046f483d2f Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Wed, 16 Jul 2025 21:47:45 +0900 Subject: [PATCH 188/339] =?UTF-8?q?[Feature]=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=97=90=EB=9F=AC=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=B6=94=EA=B0=80=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/apiPayload/code/status/ErrorStatus.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index e417af2..a2d5e4e 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -66,6 +66,11 @@ public enum ErrorStatus implements BaseErrorCode { PROMPT_LOADING_FAIL(HttpStatus.BAD_REQUEST, "CHATBOT4002", "프롬프트 로딩에 실패하였습니다."), REQUIRED_MESSAGES(HttpStatus.BAD_REQUEST, "CHATBOT4003", "메세지를 입력하세요."), + // 이미지 키워드 에러 + INVALID_IMAGEKEYWORD_VALUE(HttpStatus.BAD_REQUEST, "IMAGEKEYWORD4001", "잘못된 키워드값입니다. 키워드 값을 확인해주세요."), + EXPIRED_IMAGEKEYWORD_RESULT(HttpStatus.REQUEST_TIMEOUT, "IMAGEKEYWORD4002", "생성할 이미지 키워드 결과가 만료되었습니다."), + ALREADY_KEYWORD_NAME(HttpStatus.BAD_REQUEST, "IMAGEKEYWORD4003", "동일한 이름으로 저장된 결과가 존재합니다."), + INVALID_IMAGEKWEYWORD_ID(HttpStatus.BAD_REQUEST, "IMAGEKEYWORD4004", "해당 ID의 이미지 결과가 존재하지 않습니다."), // 예시,,, ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); From 1597f5a03ecd6343ba9cea10805c520b49277b69 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Wed, 16 Jul 2025 21:48:40 +0900 Subject: [PATCH 189/339] =?UTF-8?q?[Feature]=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20Controller,=20Service=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EC=83=9D=EC=84=B1=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../imagekeyword/ImageKeywordService.java | 4 +++ .../imagekeyword/ImageKeywordServiceImpl.java | 12 ++++++++ .../controller/ImageKeywordController.java | 28 +++++++++++++++++++ 3 files changed, 44 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordService.java create mode 100644 src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java diff --git a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordService.java b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordService.java new file mode 100644 index 0000000..e2d79f0 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordService.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.service.imagekeyword; + +public interface ImageKeywordService { +} diff --git a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java new file mode 100644 index 0000000..67198f1 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java @@ -0,0 +1,12 @@ +package PerfumeOnMe.spring.service.imagekeyword; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class ImageKeywordServiceImpl implements ImageKeywordService { +} diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java b/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java new file mode 100644 index 0000000..c293d68 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java @@ -0,0 +1,28 @@ +package PerfumeOnMe.spring.web.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import PerfumeOnMe.spring.service.imagekeyword.ImageKeywordService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/image-keyword") +@Tag(name = "Image-Keyword", description = "이미지키워드 API") +public class ImageKeywordController { + private final ImageKeywordService imageKeywordService; + + // 이미지키워드 목록 조회 API + @GetMapping("/result/list") + @Operation( + summary = "이미지키워드 목록 조회(마이페이지)", + description = "사용자가 저장한 이미지키워드 목록을 조회하는 API입니다.", + // responses = {} + ) + +} From c5f212f5702718f6fa7a1f757a2435701e0f7ffa Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Wed, 16 Jul 2025 21:49:08 +0900 Subject: [PATCH 190/339] =?UTF-8?q?[Feature]=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20DTO=20=EC=83=9D=EC=84=B1(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../imagekeyword/ImageKeywordRequestDTO.java | 4 ++++ .../imagekeyword/ImageKeywordResponseDTO.java | 22 +++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordRequestDTO.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordResponseDTO.java diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordRequestDTO.java new file mode 100644 index 0000000..d0df577 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordRequestDTO.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.web.dto.imagekeyword; + +public class ImageKeywordRequestDTO { +} diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordResponseDTO.java new file mode 100644 index 0000000..06ff102 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordResponseDTO.java @@ -0,0 +1,22 @@ +package PerfumeOnMe.spring.web.dto.imagekeyword; + +import java.time.LocalDateTime; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class ImageKeywordResponseDTO { + + // 이미지키워드 목록 조회 응답 DTO + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class ImageKeywordListResponseDTO { + private Long imageKeywordId; + private String savedName; + private LocalDateTime createdAt; + } +} From 8ea7a93fd87e7f153523c91808d666fd21c19410 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Wed, 16 Jul 2025 22:33:35 +0900 Subject: [PATCH 191/339] =?UTF-8?q?[Feature]=20=EC=A0=80=EC=9E=A5=EB=90=9C?= =?UTF-8?q?=20=EC=9D=B4=EB=AF=B8=EC=A7=80=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=EA=B5=AC=ED=98=84(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../converter/ImageKeywordConverter.java | 20 ++++++++++ .../imagekeyword/ImageKeywordRepository.java | 12 ++++++ .../imagekeyword/ImageKeywordService.java | 5 +++ .../imagekeyword/ImageKeywordServiceImpl.java | 23 ++++++++++++ .../controller/ImageKeywordController.java | 37 +++++++++++++++++-- 5 files changed, 94 insertions(+), 3 deletions(-) create mode 100644 src/main/java/PerfumeOnMe/spring/converter/ImageKeywordConverter.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/imagekeyword/ImageKeywordRepository.java diff --git a/src/main/java/PerfumeOnMe/spring/converter/ImageKeywordConverter.java b/src/main/java/PerfumeOnMe/spring/converter/ImageKeywordConverter.java new file mode 100644 index 0000000..07c671a --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/converter/ImageKeywordConverter.java @@ -0,0 +1,20 @@ +package PerfumeOnMe.spring.converter; + +import java.util.List; + +import PerfumeOnMe.spring.domain.ImageKeyword; +import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordResponseDTO; + +public class ImageKeywordConverter { + + public static List toImageKeywordListResponse( + List imageKeywords) { + return imageKeywords.stream() + .map(imageKeyword -> ImageKeywordResponseDTO.ImageKeywordListResponseDTO.builder() + .imageKeywordId(imageKeyword.getId()) + .savedName(imageKeyword.getSavedName()) + .createdAt(imageKeyword.getCreatedAt()) + .build()) + .toList(); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/imagekeyword/ImageKeywordRepository.java b/src/main/java/PerfumeOnMe/spring/repository/imagekeyword/ImageKeywordRepository.java new file mode 100644 index 0000000..5a2350e --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/imagekeyword/ImageKeywordRepository.java @@ -0,0 +1,12 @@ +package PerfumeOnMe.spring.repository.imagekeyword; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.domain.ImageKeyword; +import PerfumeOnMe.spring.domain.User; + +public interface ImageKeywordRepository extends JpaRepository { + List findAllByUserOrderByCreatedAtDesc(User user); +} diff --git a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordService.java b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordService.java index e2d79f0..ae2cece 100644 --- a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordService.java +++ b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordService.java @@ -1,4 +1,9 @@ package PerfumeOnMe.spring.service.imagekeyword; +import java.util.List; + +import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordResponseDTO; + public interface ImageKeywordService { + List getImageKeywordList(Long userId); } diff --git a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java index 67198f1..6c87ff9 100644 --- a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java @@ -1,12 +1,35 @@ package PerfumeOnMe.spring.service.imagekeyword; +import java.util.List; + import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.converter.ImageKeywordConverter; +import PerfumeOnMe.spring.domain.ImageKeyword; +import PerfumeOnMe.spring.domain.User; +import PerfumeOnMe.spring.repository.imagekeyword.ImageKeywordRepository; +import PerfumeOnMe.spring.repository.user.UserRepository; +import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordResponseDTO; import lombok.RequiredArgsConstructor; @Service @RequiredArgsConstructor @Transactional public class ImageKeywordServiceImpl implements ImageKeywordService { + private final ImageKeywordRepository imageKeywordRepository; + private final UserRepository userRepository; + + @Override + @Transactional(readOnly = true) + public List getImageKeywordList(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + + List imageKeywords = imageKeywordRepository.findAllByUserOrderByCreatedAtDesc(user); + + return ImageKeywordConverter.toImageKeywordListResponse(imageKeywords); + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java b/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java index c293d68..8936568 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java @@ -1,12 +1,21 @@ package PerfumeOnMe.spring.web.controller; +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; import PerfumeOnMe.spring.service.imagekeyword.ImageKeywordService; +import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordResponseDTO; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @@ -20,9 +29,31 @@ public class ImageKeywordController { // 이미지키워드 목록 조회 API @GetMapping("/result/list") @Operation( - summary = "이미지키워드 목록 조회(마이페이지)", - description = "사용자가 저장한 이미지키워드 목록을 조회하는 API입니다.", - // responses = {} + summary = "이미지 키워드 목록 조회 (마이페이지)", + description = "해당 유저가 저장한 이미지 키워드 결과 목록을 조회합니다.\n\n" + + "마이페이지 내 '추천 결과' 영역에 출력되는 카드들의 리스트 데이터입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON200", + description = "요청에 성공하였습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ImageKeywordResponseDTO.ImageKeywordListResponseDTO.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON401", + description = "인증이 필요합니다. 액세스 토큰을 입력해주세요." + ) + } ) + public ResponseEntity>> getImageKeywordList( + @Parameter(hidden = true) + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + List result = imageKeywordService.getImageKeywordList( + userDetails.getUserId()); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } } From 912627da23bbd11d630d78b91bbba4f19d41fad4 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Thu, 17 Jul 2025 16:03:16 +0900 Subject: [PATCH 192/339] =?UTF-8?q?[Feature]=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=9D=91=EB=8B=B5=20DTO=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20(#60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/web/dto/user/UserResponseDTO.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/user/UserResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/user/UserResponseDTO.java index b6c968d..2d2b509 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/user/UserResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/user/UserResponseDTO.java @@ -1,5 +1,7 @@ package PerfumeOnMe.spring.web.dto.user; +import java.util.List; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -14,4 +16,14 @@ public class UserResponseDTO { public static class SignupResult { private Long userId; } + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class MyPageProfileResponse { + private String nickName; + private String imageUrl; + private List preferredNotes; + } } From 99845b863a4dcd91171c6a3f041b53547f31c772 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Thu, 17 Jul 2025 16:06:47 +0900 Subject: [PATCH 193/339] =?UTF-8?q?[Feature]=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EC=A6=90=EA=B2=A8=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84(#60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/converter/UserConverter.java | 16 +++++++ .../UserFragranceRepository.java | 4 ++ .../spring/service/user/UserService.java | 7 +++ .../spring/service/user/UserServiceImpl.java | 44 +++++++++++++++++++ .../spring/web/controller/UserController.java | 37 ++++++++++++++++ 5 files changed, 108 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/converter/UserConverter.java b/src/main/java/PerfumeOnMe/spring/converter/UserConverter.java index 4d6f64a..ba47b3c 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/UserConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/UserConverter.java @@ -1,5 +1,8 @@ package PerfumeOnMe.spring.converter; +import java.util.List; +import java.util.stream.Collectors; + import PerfumeOnMe.spring.domain.User; import PerfumeOnMe.spring.web.dto.user.UserResponseDTO; @@ -20,4 +23,17 @@ public static UserResponseDTO.SignupResult toSignupResult(User user) { .userId(user.getId()) .build(); } + + // 사용자의 프로필 조회 DTO 반환 + public static UserResponseDTO.MyPageProfileResponse toMyPageProfileResponse(User user) { + List preferredNotes = user.getUserNoteList().stream() + .map(userNote -> userNote.getNote().getName()) + .collect(Collectors.toList()); + + return UserResponseDTO.MyPageProfileResponse.builder() + .nickName(user.getNickname()) + .imageUrl(user.getImageURL()) + .preferredNotes(preferredNotes) + .build(); + } } diff --git a/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepository.java b/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepository.java index 2c1d8b2..67a0397 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepository.java @@ -2,6 +2,8 @@ import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import PerfumeOnMe.spring.domain.Fragrance; @@ -15,4 +17,6 @@ public interface UserFragranceRepository extends JpaRepository findAllByUserId(Long userId, Pageable pageable); + } diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserService.java b/src/main/java/PerfumeOnMe/spring/service/user/UserService.java index 2cfe21c..61f9777 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserService.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserService.java @@ -2,6 +2,8 @@ import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; +import PerfumeOnMe.spring.web.dto.fragrance.FragranceRequestDTO; +import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; import PerfumeOnMe.spring.web.dto.user.UserRequestDTO; import PerfumeOnMe.spring.web.dto.user.UserResponseDTO; import jakarta.servlet.http.HttpServletRequest; @@ -20,4 +22,9 @@ public interface UserService { void onboarding(UserRequestDTO.Onboarding request, CustomUserDetails userDetails); void updateUserNote(UserRequestDTO.UserNoteUpdate request, CustomUserDetails userDetails); + + UserResponseDTO.MyPageProfileResponse getUserProfile(Long userId); + + FragranceResponseDTO.FragranceSearchFinalResult getFavoriteFragrances( + FragranceRequestDTO.FragranceAllRequest request, Long userId); } diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java index 9034ecc..923ff98 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java @@ -2,7 +2,10 @@ import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.password.PasswordEncoder; @@ -18,15 +21,21 @@ import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; import PerfumeOnMe.spring.config.security.auth.token.JwtAuthenticationToken; import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; +import PerfumeOnMe.spring.converter.FragranceConverter; import PerfumeOnMe.spring.converter.UserConverter; import PerfumeOnMe.spring.converter.UserNoteConverter; +import PerfumeOnMe.spring.domain.Fragrance; import PerfumeOnMe.spring.domain.Note; import PerfumeOnMe.spring.domain.User; import PerfumeOnMe.spring.domain.enums.Social; +import PerfumeOnMe.spring.domain.mapping.UserFragrance; import PerfumeOnMe.spring.domain.mapping.UserNote; import PerfumeOnMe.spring.repository.note.NoteRepository; import PerfumeOnMe.spring.repository.user.UserRepository; +import PerfumeOnMe.spring.repository.userFragrance.UserFragranceRepository; import PerfumeOnMe.spring.repository.userNote.UserNoteRepository; +import PerfumeOnMe.spring.web.dto.fragrance.FragranceRequestDTO; +import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; import PerfumeOnMe.spring.web.dto.user.UserRequestDTO; import PerfumeOnMe.spring.web.dto.user.UserResponseDTO; import jakarta.servlet.http.HttpServletRequest; @@ -46,6 +55,7 @@ public class UserServiceImpl implements UserService { private final UserDetailsService userDetailsService; private final UserNoteRepository userNoteRepository; private final NoteRepository noteRepository; + private final UserFragranceRepository userFragranceRepository; // 사용자 회원가입 @Override @@ -183,4 +193,38 @@ public void updateUserNote(UserRequestDTO.UserNoteUpdate request, CustomUserDeta // 온보딩 요청 정보 설정 - noteCategoryId saveUserNote(findUser, request.getNoteCategoryId()); } + + // 마이페이지 프로필 조회 - 닉네임, 선호하는 향 3가지, 프로필 사진 + @Override + public UserResponseDTO.MyPageProfileResponse getUserProfile(Long userId) { + // 사용자 조회 + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + + return UserConverter.toMyPageProfileResponse(user); + } + + // 마이페이지 즐겨찾기 목록 조회 + @Override + public FragranceResponseDTO.FragranceSearchFinalResult getFavoriteFragrances( + FragranceRequestDTO.FragranceAllRequest request, Long userId) { + + PageRequest pageable = PageRequest.of(request.getPage(), request.getSize()); + Page userFragranceList = userFragranceRepository.findAllByUserId(userId, pageable); + + List content = userFragranceList.getContent().stream() + .map(fragrance -> { // fragrance = UserFragrance + Fragrance f = fragrance.getFragrance(); + boolean liked = (userId != null) && Like(userId, f.getId()); + return FragranceConverter.toSearchResultDto(f, liked); + }) + .collect(Collectors.toList()); + + return FragranceConverter.toSearchFinalResult(content, userFragranceList.hasNext()); + } + + // 사용자 id 와 향수 id 를 받아와 즐겨찾기 테이블에 해댱 향수가 있는지 없는지 확인하는 메서드 + private boolean Like(Long userId, Long fragranceId) { + return userFragranceRepository.existsByUserIdAndFragranceId(userId, fragranceId); + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java index ab67395..19bebf8 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java @@ -4,6 +4,8 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -16,6 +18,8 @@ import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; import PerfumeOnMe.spring.service.user.UserService; +import PerfumeOnMe.spring.web.dto.fragrance.FragranceRequestDTO; +import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; import PerfumeOnMe.spring.web.dto.user.UserRequestDTO; import PerfumeOnMe.spring.web.dto.user.UserResponseDTO; import io.swagger.v3.oas.annotations.Operation; @@ -129,4 +133,37 @@ public ResponseEntity> updateUserNote(@Valid @RequestBody Us userService.updateUserNote(request, userDetails); return ResponseEntity.ok().body(ApiResponse.onSuccess(null)); } + + @GetMapping("/me") + @Operation( + summary = "프로필 조회 API", + description = "사용자의 프로필을 조회하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다."), + } + ) + public ResponseEntity> getUserProfile( + @AuthenticationPrincipal CustomUserDetails userDetails) { + Long userId = userDetails.getUserId(); + UserResponseDTO.MyPageProfileResponse response = userService.getUserProfile(userId); + return ResponseEntity.ok(ApiResponse.onSuccess(response)); + } + + @GetMapping("/favorites") + @Operation( + summary = "즐겨찾기 목록 조회 API", + description = "사용자의 즐겨찾기 목록을 조회하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), + } + ) + public ResponseEntity> getFavoriteFragrances( + @Valid @ModelAttribute FragranceRequestDTO.FragranceAllRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + Long userId = userDetails.getUserId(); + FragranceResponseDTO.FragranceSearchFinalResult favorites = userService.getFavoriteFragrances(request, + userId); + return ResponseEntity.ok(ApiResponse.onSuccess(favorites)); + } } From 204fbedd342496867d167b534c643b23e41951c0 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Thu, 17 Jul 2025 16:09:53 +0900 Subject: [PATCH 194/339] =?UTF-8?q?[Fix]=20DTO=20=EB=B0=98=ED=99=98=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=83=9D=EC=84=B1(#60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/converter/FragranceConverter.java | 11 ++++ .../fragrance/FragranceServiceImpl.java | 50 ++++++------------- 2 files changed, 26 insertions(+), 35 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java index 42bbf63..f431379 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java @@ -133,6 +133,17 @@ public static FragranceResponseDTO.FragranceSearchResult toSearchResultDto(Fragr .build(); } + // 향수 목록 전체 반환 dto + public static FragranceResponseDTO.FragranceSearchFinalResult toSearchFinalResult( + List content, + boolean hasNext + ) { + return FragranceResponseDTO.FragranceSearchFinalResult.builder() + .content(content) + .hasNext(hasNext) + .build(); + } + // 향수 즐겨찾기 등록 API public static FragranceResponseDTO.FavoriteResponseDTO toFavoriteResponseDTO(UserFragrance userFragrance) { return FragranceResponseDTO.FavoriteResponseDTO.builder() diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java index 3093ea7..4b0e43f 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java @@ -54,23 +54,11 @@ public FragranceResponseDTO.FragranceDetailResult getFragranceDetail(Long fragra // 향수 검색 API @Override public FragranceResponseDTO.FragranceSearchFinalResult searchFragrances( - FragranceRequestDTO.FragranceSearchRequest request, - Long userId) { + FragranceRequestDTO.FragranceSearchRequest request, Long userId) { PageRequest pageable = PageRequest.of(request.getPage(), request.getSize()); Page fragrancePage = fragranceRepository.findBySearchKeyword(request.getKeyword(), pageable); - List content = fragrancePage.getContent().stream() - .map(fragrance -> { - // 즐겨찾기 확인 - boolean liked = (userId != null) && Like(userId, fragrance.getId()); - return FragranceConverter.toSearchResultDto(fragrance, liked); - }) - .collect(Collectors.toList()); - - return FragranceResponseDTO.FragranceSearchFinalResult.builder() - .content(content) - .hasNext(fragrancePage.hasNext()) - .build(); + return getFragranceSearchFinalResult(userId, fragrancePage); } @@ -154,18 +142,7 @@ public FragranceResponseDTO.FragranceSearchFinalResult searchFragrancesByFilter( Pageable pageable = PageRequest.of(request.getPage(), request.getSize(), Sort.by("name")); Page fragrancePage = fragranceRepository.findByFilter(request, pageable); - List content = fragrancePage.getContent().stream() - .map(fragrance -> { - // 즐겨찾기 확인 - boolean liked = (userId != null) && Like(userId, fragrance.getId()); - return FragranceConverter.toSearchResultDto(fragrance, liked); - }) - .collect(Collectors.toList()); - - return FragranceResponseDTO.FragranceSearchFinalResult.builder() - .content(content) - .hasNext(fragrancePage.hasNext()) - .build(); + return getFragranceSearchFinalResult(userId, fragrancePage); } // 향수 전체 리스트 API @@ -175,6 +152,17 @@ public FragranceResponseDTO.FragranceSearchFinalResult getFragranceListAll( PageRequest pageable = PageRequest.of(request.getPage(), request.getSize()); Page fragrancePage = fragranceRepository.findAll(pageable); // 향수 전체 목록 가져오기 + return getFragranceSearchFinalResult(userId, fragrancePage); + } + + // 사용자 id 와 향수 id 를 받아와 즐겨찾기 테이블에 해댱 향수가 있는지 없는지 확인하는 메서드 + private boolean Like(Long userId, Long fragranceId) { + return userFragranceRepository.existsByUserIdAndFragranceId(userId, fragranceId); + } + + // 향수 목록 dto 반환 및 paging 처리 메서드 생성 (중복제거) + private FragranceResponseDTO.FragranceSearchFinalResult getFragranceSearchFinalResult(Long userId, + Page fragrancePage) { List content = fragrancePage.getContent().stream() .map(fragrance -> { // 즐겨찾기 확인 @@ -183,15 +171,7 @@ public FragranceResponseDTO.FragranceSearchFinalResult getFragranceListAll( }) .collect(Collectors.toList()); - return FragranceResponseDTO.FragranceSearchFinalResult.builder() - .content(content) - .hasNext(fragrancePage.hasNext()) - .build(); - } - - // 사용자 id 와 향수 id 를 받아와 즐겨찾기 테이블에 해댱 향수가 있는지 없는지 확인하는 메서드 - private boolean Like(Long userId, Long fragranceId) { - return userFragranceRepository.existsByUserIdAndFragranceId(userId, fragranceId); + return FragranceConverter.toSearchFinalResult(content, fragrancePage.hasNext()); } } From b8098a68ee0d8c90b53698288d3afc3e007bba67 Mon Sep 17 00:00:00 2001 From: bulee5328 Date: Fri, 18 Jul 2025 00:59:54 +0900 Subject: [PATCH 195/339] =?UTF-8?q?[Feature]=20=EB=8B=A4=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=EB=A6=AC=20=EC=88=98=EC=A0=95=20API=20=EA=B5=AC=ED=98=84=20(#6?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apiPayload/code/status/ErrorStatus.java | 4 ++++ .../apiPayload/code/status/SuccessStatus.java | 3 ++- .../spring/domain/mapping/Diary.java | 6 +++++ .../spring/service/Diary/DiaryService.java | 3 +++ .../service/Diary/DiaryServiceImpl.java | 18 +++++++++++++++ .../web/controller/DiaryController.java | 22 +++++++++++++++++++ .../spring/web/dto/diary/DiaryRequestDTO.java | 8 +++++++ 7 files changed, 63 insertions(+), 1 deletion(-) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index 42596af..6420fed 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -66,6 +66,10 @@ public enum ErrorStatus implements BaseErrorCode { PROMPT_LOADING_FAIL(HttpStatus.BAD_REQUEST, "CHATBOT4002", "프롬프트 로딩에 실패하였습니다."), REQUIRED_MESSAGES(HttpStatus.BAD_REQUEST, "CHATBOT4003", "메세지를 입력하세요."), + // 다이어리 에러 + DIARY_NOT_FOUND(HttpStatus.BAD_REQUEST, "DIARY4001", "해당 다이어리를 찾을 수 없습니다."), + USER_DIARY_FORBIDDEN(HttpStatus.BAD_REQUEST, "DIARY4002", "다이어리 소유자의 요청이 아닙니다."), + // 예시,,, ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/SuccessStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/SuccessStatus.java index a0e5d46..18ee889 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/SuccessStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/SuccessStatus.java @@ -13,7 +13,8 @@ public enum SuccessStatus implements BaseCode { // 일반적인 응답 _OK(HttpStatus.OK, "COMMON200", "성공입니다."), - _CREATED(HttpStatus.OK, "COMMON201", "리소스를 성공적으로 생성했습니다."); + _CREATED(HttpStatus.OK, "COMMON201", "리소스를 성공적으로 생성했습니다."), + DIARY_UPDATED(HttpStatus.OK, "DIARY200", "다이어리가 수정되었습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/Diary.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/Diary.java index 2ef77a5..78f634c 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/Diary.java +++ b/src/main/java/PerfumeOnMe/spring/domain/mapping/Diary.java @@ -51,4 +51,10 @@ public class Diary extends BaseEntity { @Column(columnDefinition = "TEXT", nullable = false) private String content; + + // 다이어리 수정하는 메서드 + public void updateFragranceNameAndContent(String fragranceName, String content) { + this.fragranceName = fragranceName; + this.content = content; + } } diff --git a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java index 5dd4a51..9fd21df 100644 --- a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java +++ b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java @@ -7,4 +7,7 @@ public interface DiaryService { // 다이어리 추가 API DiaryResponseDTO.AddDiaryResponse addDiary(Long userId, DiaryRequestDTO.AddDiaryRequest addDiaryRequest); + + // 다이어리 수정 API + void updateDiary(Long userId, Long diaryId, DiaryRequestDTO.UpdateDiaryRequest updateDiaryRequest); } diff --git a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java index 6faec16..32362f8 100644 --- a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java @@ -38,4 +38,22 @@ public DiaryResponseDTO.AddDiaryResponse addDiary(Long userId, DiaryRequestDTO.A diaryRepository.save(diary); return DiaryConverter.addDiaryResponseDTO(diary); } + + // 다이어리 수정 API + @Override + public void updateDiary(Long userId, Long diaryId, DiaryRequestDTO.UpdateDiaryRequest updateDiaryRequest) { + // 다이어리 존재 여부 확인 + Diary diary = diaryRepository.findById(diaryId) + .orElseThrow(() -> new GeneralException(ErrorStatus.DIARY_NOT_FOUND)); + + // 다이어리 소유자 확인 + if (!diary.getUser().getId().equals(userId)) { + throw new GeneralException(ErrorStatus.USER_DIARY_FORBIDDEN); + } + + diary.updateFragranceNameAndContent(updateDiaryRequest.getFragranceName(), updateDiaryRequest.getContent()); + + // 다이어리 저장 + diaryRepository.save(diary); + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java b/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java index 193051f..4167a19 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java @@ -2,12 +2,15 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.apiPayload.code.status.SuccessStatus; import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; import PerfumeOnMe.spring.service.Diary.DiaryService; import PerfumeOnMe.spring.web.dto.diary.DiaryRequestDTO; @@ -43,4 +46,23 @@ public ResponseEntity> addDiary( DiaryResponseDTO.AddDiaryResponse result = diaryService.addDiary(userDetails.getUserId(), request); return ResponseEntity.ok(ApiResponse.onSuccess(result)); } + + // 다이어리 수정 API + @PatchMapping("/{diaryId}/update") + @Operation( + summary = "다이어리 수정", + description = "다이어리를 수정하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "다이어리가 수정되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "DIARY4001", description = "해당 다이어리를 찾을 수 없습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "DIARY4002", description = "다이어리 소유자의 요청이 아닙니다.") + } + ) + public ResponseEntity> updateDiary( + @PathVariable Long diaryId, + @RequestBody @Valid DiaryRequestDTO.UpdateDiaryRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + diaryService.updateDiary(userDetails.getUserId(), diaryId, request); + return ResponseEntity.ok(ApiResponse.of(SuccessStatus.DIARY_UPDATED, null)); + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryRequestDTO.java index 8431045..438a78d 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryRequestDTO.java @@ -16,4 +16,12 @@ public static class AddDiaryRequest { private LocalDate date; } + // 다이어리 수정 요청 DTO + @Getter + @Setter + public static class UpdateDiaryRequest { + private String fragranceName; + private String content; + } + } From 3a28da5ddc882b24673495fc313b575fb9571c5b Mon Sep 17 00:00:00 2001 From: bulee5328 Date: Fri, 18 Jul 2025 20:55:26 +0900 Subject: [PATCH 196/339] =?UTF-8?q?[Feature]=20=EB=8B=A4=EC=9D=B4=EC=96=B4?= =?UTF-8?q?=EB=A6=AC=20=EC=82=AD=EC=A0=9C=20API=20=EA=B5=AC=ED=98=84=20(#6?= =?UTF-8?q?7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apiPayload/code/status/SuccessStatus.java | 3 ++- .../spring/service/Diary/DiaryService.java | 3 +++ .../service/Diary/DiaryServiceImpl.java | 15 +++++++++++++++ .../web/controller/DiaryController.java | 19 +++++++++++++++++++ 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/SuccessStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/SuccessStatus.java index 18ee889..6668efb 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/SuccessStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/SuccessStatus.java @@ -14,7 +14,8 @@ public enum SuccessStatus implements BaseCode { // 일반적인 응답 _OK(HttpStatus.OK, "COMMON200", "성공입니다."), _CREATED(HttpStatus.OK, "COMMON201", "리소스를 성공적으로 생성했습니다."), - DIARY_UPDATED(HttpStatus.OK, "DIARY200", "다이어리가 수정되었습니다."); + DIARY_UPDATED(HttpStatus.OK, "DIARY200", "다이어리가 수정되었습니다."), + DIARY_DELETED(HttpStatus.OK, "DIARY201", "다이어리가 삭제되었습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java index 9fd21df..dcceeb6 100644 --- a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java +++ b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java @@ -10,4 +10,7 @@ public interface DiaryService { // 다이어리 수정 API void updateDiary(Long userId, Long diaryId, DiaryRequestDTO.UpdateDiaryRequest updateDiaryRequest); + + // 다이어리 삭제 API + void deleteDiary(Long userId, Long diaryId); } diff --git a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java index 32362f8..f72133d 100644 --- a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java @@ -56,4 +56,19 @@ public void updateDiary(Long userId, Long diaryId, DiaryRequestDTO.UpdateDiaryRe // 다이어리 저장 diaryRepository.save(diary); } + + // 다이어리 삭제 API + @Override + public void deleteDiary(Long userId, Long diaryId) { + // 다이어리 존재 여부 확인 + Diary diary = diaryRepository.findById(diaryId) + .orElseThrow(() -> new GeneralException(ErrorStatus.DIARY_NOT_FOUND)); + + // 다이어리 소유자 확인 + if (!diary.getUser().getId().equals(userId)) { + throw new GeneralException(ErrorStatus.USER_DIARY_FORBIDDEN); + } + + diaryRepository.delete(diary); + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java b/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java index 4167a19..decca9b 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java @@ -2,6 +2,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -65,4 +66,22 @@ public ResponseEntity> updateDiary( diaryService.updateDiary(userDetails.getUserId(), diaryId, request); return ResponseEntity.ok(ApiResponse.of(SuccessStatus.DIARY_UPDATED, null)); } + + // 다이어리 삭제 API + @DeleteMapping("/{diaryId}/delete") + @Operation( + summary = "다이어리 삭제", + description = "다이어리를 삭제하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "다이어리가 삭제되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "DIARY4001", description = "해당 다이어리를 찾을 수 없습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "DIARY4002", description = "다이어리 소유자의 요청이 아닙니다.") + } + ) + public ResponseEntity> deleteDiary( + @PathVariable Long diaryId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + diaryService.deleteDiary(userDetails.getUserId(), diaryId); + return ResponseEntity.ok(ApiResponse.of(SuccessStatus.DIARY_DELETED, null)); + } } From ac78ef223a4d2fab0a161ad043a948dc5878fd34 Mon Sep 17 00:00:00 2001 From: bulee5328 Date: Fri, 18 Jul 2025 23:20:14 +0900 Subject: [PATCH 197/339] =?UTF-8?q?[Feature]=20=EC=9D=BC=EB=B3=84=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=B4=EB=A6=AC=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apiPayload/code/status/ErrorStatus.java | 1 + .../repository/diary/DiaryRepository.java | 4 +++ .../spring/service/Diary/DiaryService.java | 6 ++++ .../service/Diary/DiaryServiceImpl.java | 18 ++++++++++++ .../web/controller/DiaryController.java | 26 +++++++++++++++++ .../web/dto/diary/DiaryResponseDTO.java | 28 +++++++++++++++++++ 6 files changed, 83 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index 6420fed..d141a85 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -69,6 +69,7 @@ public enum ErrorStatus implements BaseErrorCode { // 다이어리 에러 DIARY_NOT_FOUND(HttpStatus.BAD_REQUEST, "DIARY4001", "해당 다이어리를 찾을 수 없습니다."), USER_DIARY_FORBIDDEN(HttpStatus.BAD_REQUEST, "DIARY4002", "다이어리 소유자의 요청이 아닙니다."), + USER_DIARY_NOT_FOUND(HttpStatus.BAD_REQUEST, "DIARY4003", "해당 날짜에 해당하는 다이어리를 찾을 수 없습니다."), // 예시,,, ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); diff --git a/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepository.java b/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepository.java index 2a18552..6cd58aa 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepository.java @@ -1,5 +1,7 @@ package PerfumeOnMe.spring.repository.diary; +import java.time.LocalDate; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,4 +11,6 @@ public interface DiaryRepository extends JpaRepository, DiaryRepositoryCustom { Optional findById(Long id); + + List findAllByUserIdAndDate(Long userId, LocalDate date); } diff --git a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java index dcceeb6..bae7332 100644 --- a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java +++ b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java @@ -1,5 +1,8 @@ package PerfumeOnMe.spring.service.Diary; +import java.time.LocalDate; +import java.util.List; + import PerfumeOnMe.spring.web.dto.diary.DiaryRequestDTO; import PerfumeOnMe.spring.web.dto.diary.DiaryResponseDTO; @@ -13,4 +16,7 @@ public interface DiaryService { // 다이어리 삭제 API void deleteDiary(Long userId, Long diaryId); + + // 일별 다이어리 상세 조회 API + List searchDailyDiary(Long userId, LocalDate date); } diff --git a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java index f72133d..3950b9b 100644 --- a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java @@ -1,5 +1,8 @@ package PerfumeOnMe.spring.service.Diary; +import java.time.LocalDate; +import java.util.List; + import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -71,4 +74,19 @@ public void deleteDiary(Long userId, Long diaryId) { diaryRepository.delete(diary); } + + // 일별 다이어리 상세 조회 API + @Override + public List searchDailyDiary(Long userId, LocalDate date) { + // 해당 날짜의 다이어리들 조회 + List diaries = diaryRepository.findAllByUserIdAndDate(userId, date); + + if (diaries.isEmpty()) { + throw new GeneralException(ErrorStatus.USER_DIARY_NOT_FOUND); + } + + return DiaryResponseDTO.SearchDailyDiaryResponse.fromEntityList(diaries); + } } + + diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java b/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java index decca9b..6cc8097 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java @@ -1,8 +1,12 @@ package PerfumeOnMe.spring.web.controller; +import java.time.LocalDate; +import java.util.List; + import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -17,6 +21,7 @@ import PerfumeOnMe.spring.web.dto.diary.DiaryRequestDTO; import PerfumeOnMe.spring.web.dto.diary.DiaryResponseDTO; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; @@ -84,4 +89,25 @@ public ResponseEntity> deleteDiary( diaryService.deleteDiary(userDetails.getUserId(), diaryId); return ResponseEntity.ok(ApiResponse.of(SuccessStatus.DIARY_DELETED, null)); } + + // 일별 다이어리 상세 조회 API + @GetMapping("/daily/{date}") + @Operation( + summary = "일별 다이어리 상세 조회", + description = "일별 다이어리를 상세 조회하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "다이어리가 조회되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = DiaryResponseDTO.SearchDailyDiaryResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "DIARY4003", description = "해당 날짜에 해당하는 다이어리를 찾을 수 없습니다.") + } + ) + public ResponseEntity>> searchDailyDiary( + @Parameter( + description = "조회할 날짜 (예: 2025-07-17)" + ) + @PathVariable LocalDate date, + @AuthenticationPrincipal CustomUserDetails userDetails) { + List result = diaryService.searchDailyDiary(userDetails.getUserId(), + date); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java index ccff2b2..ef6af5d 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java @@ -1,7 +1,10 @@ package PerfumeOnMe.spring.web.dto.diary; import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import PerfumeOnMe.spring.domain.mapping.Diary; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -19,4 +22,29 @@ public static class AddDiaryResponse { private LocalDate date; } + // 일별 다이어리 상세 조회 응답 DTO + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class SearchDailyDiaryResponse { + private Long id; + private LocalDate date; + private String content; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static List fromEntityList(List diaries) { + return diaries.stream() + .map(diary -> SearchDailyDiaryResponse.builder() + .id(diary.getId()) + .date(diary.getDate()) + .content(diary.getContent()) + .createdAt(diary.getCreatedAt()) + .updatedAt(diary.getUpdatedAt()) + .build()) + .toList(); + } + } + } From 450a7c7bf788e3cdf67bddb06bee4778105feae4 Mon Sep 17 00:00:00 2001 From: bulee5328 Date: Sat, 19 Jul 2025 12:31:04 +0900 Subject: [PATCH 198/339] =?UTF-8?q?[Feature]=20=EC=9D=91=EB=8B=B5=20DTO=20?= =?UTF-8?q?=C3=A3fragranceName=20=EC=B6=94=EA=B0=80=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java index ef6af5d..eee7b23 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java @@ -29,6 +29,7 @@ public static class AddDiaryResponse { @NoArgsConstructor public static class SearchDailyDiaryResponse { private Long id; + private String fragranceName; private LocalDate date; private String content; private LocalDateTime createdAt; @@ -38,6 +39,7 @@ public static List fromEntityList(List diaries) return diaries.stream() .map(diary -> SearchDailyDiaryResponse.builder() .id(diary.getId()) + .fragranceName(diary.getFragranceName()) .date(diary.getDate()) .content(diary.getContent()) .createdAt(diary.getCreatedAt()) From b86b46cb24f80c2b1cb6285fda632db1bf5f61a2 Mon Sep 17 00:00:00 2001 From: bulee5328 Date: Sat, 19 Jul 2025 13:26:54 +0900 Subject: [PATCH 199/339] =?UTF-8?q?[Feature]=20=EC=9B=94=EB=B3=84=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=B4=EB=A6=AC=20=EC=A1=B0=ED=9A=8C=20API?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(#71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apiPayload/code/status/ErrorStatus.java | 1 + .../repository/diary/DiaryRepository.java | 2 ++ .../spring/service/Diary/DiaryService.java | 4 ++++ .../service/Diary/DiaryServiceImpl.java | 14 +++++++++++ .../web/controller/DiaryController.java | 23 +++++++++++++++++++ .../web/dto/diary/DiaryResponseDTO.java | 23 +++++++++++++++++++ 6 files changed, 67 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index d141a85..bdf3bf1 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -70,6 +70,7 @@ public enum ErrorStatus implements BaseErrorCode { DIARY_NOT_FOUND(HttpStatus.BAD_REQUEST, "DIARY4001", "해당 다이어리를 찾을 수 없습니다."), USER_DIARY_FORBIDDEN(HttpStatus.BAD_REQUEST, "DIARY4002", "다이어리 소유자의 요청이 아닙니다."), USER_DIARY_NOT_FOUND(HttpStatus.BAD_REQUEST, "DIARY4003", "해당 날짜에 해당하는 다이어리를 찾을 수 없습니다."), + MONTH_DIARY_NOT_FOUND(HttpStatus.BAD_REQUEST, "DIARY4004", "해당 월에 작성된 다이어리가 없습니다."), // 예시,,, ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); diff --git a/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepository.java b/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepository.java index 6cd58aa..9756473 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepository.java @@ -13,4 +13,6 @@ public interface DiaryRepository extends JpaRepository, DiaryReposi Optional findById(Long id); List findAllByUserIdAndDate(Long userId, LocalDate date); + + List findAllByUserIdAndDateBetween(Long userId, LocalDate startDate, LocalDate endDate); } diff --git a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java index bae7332..b162e81 100644 --- a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java +++ b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java @@ -19,4 +19,8 @@ public interface DiaryService { // 일별 다이어리 상세 조회 API List searchDailyDiary(Long userId, LocalDate date); + + // 월별 다이어리 조회 API + List searchMonthlyDiary(Long userId, LocalDate startDate, + LocalDate endDate); } diff --git a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java index 3950b9b..d8bf991 100644 --- a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java @@ -87,6 +87,20 @@ public List searchDailyDiary(Long use return DiaryResponseDTO.SearchDailyDiaryResponse.fromEntityList(diaries); } + + // 월별 다이어리 조회 API + @Override + public List searchMonthlyDiary(Long userId, LocalDate startDate, + LocalDate endDate) { + // 해당 월의 다이어리들 조회 + List diaries = diaryRepository.findAllByUserIdAndDateBetween(userId, startDate, endDate); + + if (diaries.isEmpty()) { + throw new GeneralException(ErrorStatus.MONTH_DIARY_NOT_FOUND); + } + + return DiaryResponseDTO.SearchMonthlyDiaryResponse.fromEntityList(diaries); + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java b/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java index 6cc8097..323fb2d 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java @@ -110,4 +110,27 @@ public ResponseEntity>> searchMonthlyDiary( + @PathVariable Integer year, + @PathVariable Integer month, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + LocalDate startDate = LocalDate.of(year, month, 1); + LocalDate endDate = startDate.withDayOfMonth(startDate.lengthOfMonth()); + + List result = + diaryService.searchMonthlyDiary(userDetails.getUserId(), startDate, endDate); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java index eee7b23..e184043 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java @@ -49,4 +49,27 @@ public static List fromEntityList(List diaries) } } + // 월별 다이어리 조회 응답 DTO + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class SearchMonthlyDiaryResponse { + private Long id; + private String fragranceName; + private LocalDate date; + private String content; + + public static List fromEntityList(List diaries) { + return diaries.stream() + .map(diary -> SearchMonthlyDiaryResponse.builder() + .id(diary.getId()) + .fragranceName(diary.getFragranceName()) + .date(diary.getDate()) + .content(diary.getContent()) + .build()) + .toList(); + } + } + } From 58cd6c3672438c01863a71a60e3776b2f3477e56 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 20 Jul 2025 20:25:15 +0900 Subject: [PATCH 200/339] =?UTF-8?q?[Feature]=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EA=B2=B0=EA=B3=BC=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EC=A1=B0=ED=9A=8C=20API=EA=B5=AC=ED=98=84(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apiPayload/code/status/ErrorStatus.java | 5 +- .../converter/ImageKeywordConverter.java | 59 +++++++++++++++++++ .../imagekeyword/ImageKeywordRepository.java | 4 ++ .../ImageKeywordDescriptionRepository.java | 13 ++++ .../imagekeyword/ImageKeywordService.java | 2 + .../imagekeyword/ImageKeywordServiceImpl.java | 14 +++++ .../controller/ImageKeywordController.java | 20 +++++++ .../imagekeyword/ImageKeywordResponseDTO.java | 28 +++++++++ 8 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 src/main/java/PerfumeOnMe/spring/repository/imagekeyworddescription/ImageKeywordDescriptionRepository.java diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index a2d5e4e..f2f99e1 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -70,7 +70,10 @@ public enum ErrorStatus implements BaseErrorCode { INVALID_IMAGEKEYWORD_VALUE(HttpStatus.BAD_REQUEST, "IMAGEKEYWORD4001", "잘못된 키워드값입니다. 키워드 값을 확인해주세요."), EXPIRED_IMAGEKEYWORD_RESULT(HttpStatus.REQUEST_TIMEOUT, "IMAGEKEYWORD4002", "생성할 이미지 키워드 결과가 만료되었습니다."), ALREADY_KEYWORD_NAME(HttpStatus.BAD_REQUEST, "IMAGEKEYWORD4003", "동일한 이름으로 저장된 결과가 존재합니다."), - INVALID_IMAGEKWEYWORD_ID(HttpStatus.BAD_REQUEST, "IMAGEKEYWORD4004", "해당 ID의 이미지 결과가 존재하지 않습니다."), + INVALID_IMAGEKEYWORD_ID(HttpStatus.BAD_REQUEST, "IMAGEKEYWORD4004", "해당 ID의 이미지 결과가 존재하지 않습니다."), + + //JSON 파싱 에러 + JSON_PARSE_ERROR(HttpStatus.BAD_REQUEST, "JSON4001", "JSON 파싱에 실패했습니다."), // 예시,,, ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); diff --git a/src/main/java/PerfumeOnMe/spring/converter/ImageKeywordConverter.java b/src/main/java/PerfumeOnMe/spring/converter/ImageKeywordConverter.java index 07c671a..f1de5c5 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/ImageKeywordConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/ImageKeywordConverter.java @@ -1,8 +1,17 @@ package PerfumeOnMe.spring.converter; import java.util.List; +import java.util.stream.Stream; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; import PerfumeOnMe.spring.domain.ImageKeyword; +import PerfumeOnMe.spring.domain.ImageKeywordDescription; +import PerfumeOnMe.spring.domain.enums.KeywordCategory; +import PerfumeOnMe.spring.repository.imagekeyworddescription.ImageKeywordDescriptionRepository; import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordResponseDTO; public class ImageKeywordConverter { @@ -17,4 +26,54 @@ public static List toImageK .build()) .toList(); } + + public static ImageKeywordResponseDTO.ImageKeywordDetailResponseDTO toImageKeywordDetailResponse( + ImageKeyword keyword, ImageKeywordDescriptionRepository descriptionRepo + ) { + List keywords = List.of( + keyword.getAmbience().getDisplayName(), + keyword.getStyle().getDisplayName(), + keyword.getSeason().getDisplayName(), + keyword.getPersonality().getDisplayName(), + keyword.getGender().getDisplayName() + ); + + List descriptions = Stream.of( + new EnumWithCategory(keyword.getAmbience().name(), KeywordCategory.AMBIENCE), + new EnumWithCategory(keyword.getStyle().name(), KeywordCategory.STYLE), + new EnumWithCategory(keyword.getSeason().name(), KeywordCategory.SEASON), + new EnumWithCategory(keyword.getPersonality().name(), KeywordCategory.PERSONALITY), + new EnumWithCategory(keyword.getGender().name(), KeywordCategory.GENDER) + ) + .map(pair -> descriptionRepo.findByKeywordAndCategory(pair.keyword, pair.category) + .map(ImageKeywordDescription::getDescription).orElse("")) + .toList(); + + List recommendations = + parseFragranceJson(keyword.getRecommendedFragranceJson()); + + return ImageKeywordResponseDTO.ImageKeywordDetailResponseDTO.builder() + .savedName(keyword.getSavedName()) + .keywords(keywords) + .descriptions(String.join(" ", descriptions)) + .scenario(keyword.getScenario()) + .characterImageUrl(keyword.getImageUrl()) + .recommendations(recommendations) + .build(); + } + + private static List parseFragranceJson( + String json) { + try { + ObjectMapper mapper = new ObjectMapper(); + return mapper.readValue(json, + new TypeReference>() { + }); + } catch (Exception e) { + throw new GeneralException(ErrorStatus.JSON_PARSE_ERROR); + } + } + + private record EnumWithCategory(String keyword, KeywordCategory category) { + } } diff --git a/src/main/java/PerfumeOnMe/spring/repository/imagekeyword/ImageKeywordRepository.java b/src/main/java/PerfumeOnMe/spring/repository/imagekeyword/ImageKeywordRepository.java index 5a2350e..b449ed2 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/imagekeyword/ImageKeywordRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/imagekeyword/ImageKeywordRepository.java @@ -1,6 +1,7 @@ package PerfumeOnMe.spring.repository.imagekeyword; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,4 +10,7 @@ public interface ImageKeywordRepository extends JpaRepository { List findAllByUserOrderByCreatedAtDesc(User user); + + Optional findByIdAndUser(Long id, User user); + } diff --git a/src/main/java/PerfumeOnMe/spring/repository/imagekeyworddescription/ImageKeywordDescriptionRepository.java b/src/main/java/PerfumeOnMe/spring/repository/imagekeyworddescription/ImageKeywordDescriptionRepository.java new file mode 100644 index 0000000..8be1fa6 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/imagekeyworddescription/ImageKeywordDescriptionRepository.java @@ -0,0 +1,13 @@ +package PerfumeOnMe.spring.repository.imagekeyworddescription; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.domain.ImageKeywordDescription; +import PerfumeOnMe.spring.domain.enums.KeywordCategory; + +public interface ImageKeywordDescriptionRepository extends JpaRepository { + Optional findByKeywordAndCategory(String keyword, KeywordCategory category); + +} diff --git a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordService.java b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordService.java index ae2cece..cfae6f5 100644 --- a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordService.java +++ b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordService.java @@ -6,4 +6,6 @@ public interface ImageKeywordService { List getImageKeywordList(Long userId); + + ImageKeywordResponseDTO.ImageKeywordDetailResponseDTO getImageKeywordDetail(Long userId, Long imageKeywordId); } diff --git a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java index 6c87ff9..edc49ab 100644 --- a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java @@ -11,6 +11,7 @@ import PerfumeOnMe.spring.domain.ImageKeyword; import PerfumeOnMe.spring.domain.User; import PerfumeOnMe.spring.repository.imagekeyword.ImageKeywordRepository; +import PerfumeOnMe.spring.repository.imagekeyworddescription.ImageKeywordDescriptionRepository; import PerfumeOnMe.spring.repository.user.UserRepository; import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordResponseDTO; import lombok.RequiredArgsConstructor; @@ -21,6 +22,7 @@ public class ImageKeywordServiceImpl implements ImageKeywordService { private final ImageKeywordRepository imageKeywordRepository; private final UserRepository userRepository; + private final ImageKeywordDescriptionRepository imageKeywordDescriptionRepository; @Override @Transactional(readOnly = true) @@ -32,4 +34,16 @@ public List getImageKeyword return ImageKeywordConverter.toImageKeywordListResponse(imageKeywords); } + + @Override + @Transactional(readOnly = true) + public ImageKeywordResponseDTO.ImageKeywordDetailResponseDTO getImageKeywordDetail(Long userId, + Long imageKeywordId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + + ImageKeyword keyword = imageKeywordRepository.findByIdAndUser(imageKeywordId, user) + .orElseThrow(() -> new GeneralException(ErrorStatus.INVALID_IMAGEKEYWORD_ID)); + return ImageKeywordConverter.toImageKeywordDetailResponse(keyword, imageKeywordDescriptionRepository); + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java b/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java index 8936568..c916c29 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java @@ -5,6 +5,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -56,4 +57,23 @@ public ResponseEntity> getImageKeywordDetail( + @PathVariable Long imageKeywordId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + ImageKeywordResponseDTO.ImageKeywordDetailResponseDTO result = + imageKeywordService.getImageKeywordDetail(userDetails.getUserId(), imageKeywordId); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } + } diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordResponseDTO.java index 06ff102..b5e8da1 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordResponseDTO.java @@ -1,6 +1,7 @@ package PerfumeOnMe.spring.web.dto.imagekeyword; import java.time.LocalDateTime; +import java.util.List; import lombok.AllArgsConstructor; import lombok.Builder; @@ -19,4 +20,31 @@ public static class ImageKeywordListResponseDTO { private String savedName; private LocalDateTime createdAt; } + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class ImageKeywordDetailResponseDTO { + private String savedName; + private List keywords; + private String descriptions; // 설명들 join해서 + private String scenario; + private String characterImageUrl; + private List recommendations; + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class FragranceRecommendation { + private String brand; + private String name; + private String topNote; + private String middleNote; + private String baseNote; + private String description; + private List relatedKeywords; + } + } } From 824f9aeb92d617031b1e118edc9bd30bfccb5a02 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 20 Jul 2025 20:32:54 +0900 Subject: [PATCH 201/339] =?UTF-8?q?[Feature]=20build.gradle=20s3=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle b/build.gradle index 6414f32..c4c9b8a 100644 --- a/build.gradle +++ b/build.gradle @@ -79,6 +79,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-webflux' // OAuth2.0 implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + //S3 + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' } tasks.named('test') { From 28add619f5e5f473a50b195136892a281cafb17e Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 20 Jul 2025 21:53:03 +0900 Subject: [PATCH 202/339] =?UTF-8?q?[Feature]=20application-dev=20s3?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 29c071d..06de960 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -56,4 +56,18 @@ server: enabled: true openai: api-key: ${OPENAI_API_KEY} - model: gpt-4 \ No newline at end of file + model: gpt-4 + +cloud: + aws: + s3: + bucket: umc-perfume-bucket + path: + profile: user_profiles + region: + static: ap-northeast-1 + stack: + auto: false + credentials: + accessKey: ${AWS_S3_ACCESS_KEY_ID} + secretKey: ${AWS_S3_SECRET_ACCESS_KEY} \ No newline at end of file From 6948e4a98a4a60c199ac52495262eb128797467f Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 20 Jul 2025 21:54:08 +0900 Subject: [PATCH 203/339] =?UTF-8?q?[Feature]=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EA=B3=A0=EC=9C=A0=EC=84=B1=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?UUID=EC=B6=94=EA=B0=80(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/PerfumeOnMe/spring/domain/Uuid.java | 27 +++++++++++++++++++ .../repository/uuid/UuidRepository.java | 11 ++++++++ 2 files changed, 38 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/domain/Uuid.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/uuid/UuidRepository.java diff --git a/src/main/java/PerfumeOnMe/spring/domain/Uuid.java b/src/main/java/PerfumeOnMe/spring/domain/Uuid.java new file mode 100644 index 0000000..bcc6177 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/Uuid.java @@ -0,0 +1,27 @@ +package PerfumeOnMe.spring.domain; + +import PerfumeOnMe.spring.domain.base.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Builder +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class Uuid extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true) + private String uuid; +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/uuid/UuidRepository.java b/src/main/java/PerfumeOnMe/spring/repository/uuid/UuidRepository.java new file mode 100644 index 0000000..4beeab2 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/uuid/UuidRepository.java @@ -0,0 +1,11 @@ +package PerfumeOnMe.spring.repository.uuid; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.domain.Uuid; + +public interface UuidRepository extends JpaRepository { + Optional findByUuid(String uuid); +} From d4a5ef2811b13ff62c17563bf0f13f9f3530f49d Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 20 Jul 2025 21:54:55 +0900 Subject: [PATCH 204/339] =?UTF-8?q?[Feature]=20Config,=20Manager=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/aws/s3/AmazonS3Manager.java | 45 +++++++++++++++ .../spring/config/AmazonConfig.java | 56 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/aws/s3/AmazonS3Manager.java create mode 100644 src/main/java/PerfumeOnMe/spring/config/AmazonConfig.java diff --git a/src/main/java/PerfumeOnMe/spring/aws/s3/AmazonS3Manager.java b/src/main/java/PerfumeOnMe/spring/aws/s3/AmazonS3Manager.java new file mode 100644 index 0000000..2d4c853 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/aws/s3/AmazonS3Manager.java @@ -0,0 +1,45 @@ +package PerfumeOnMe.spring.aws.s3; + +import java.io.IOException; + +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; + +import PerfumeOnMe.spring.config.AmazonConfig; +import PerfumeOnMe.spring.domain.Uuid; +import PerfumeOnMe.spring.repository.uuid.UuidRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AmazonS3Manager { + + private final AmazonS3 amazonS3; + + private final AmazonConfig amazonConfig; + + private final UuidRepository uuidRepository; + + // S3에 저장할 경로(prefix + 파일이름) 생성 + public String generateProfileKeyName(Uuid uuid) { + return amazonConfig.getProfilePath() + '/' + uuid.getUuid(); + } + + // MultipartFile로 받은 파일을 S3버의 keyname 경로에 저장 + // 저장 후 URL반환 + public String uploadFile(String keyName, MultipartFile file) throws IOException { + System.out.println(keyName); + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + amazonS3.putObject(new PutObjectRequest(amazonConfig.getBucket(), keyName, file.getInputStream(), metadata)); + + return amazonS3.getUrl(amazonConfig.getBucket(), keyName).toString(); + } + +} diff --git a/src/main/java/PerfumeOnMe/spring/config/AmazonConfig.java b/src/main/java/PerfumeOnMe/spring/config/AmazonConfig.java new file mode 100644 index 0000000..739f450 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/AmazonConfig.java @@ -0,0 +1,56 @@ +package PerfumeOnMe.spring.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSCredentialsProvider; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; + +import jakarta.annotation.PostConstruct; +import lombok.Getter; + +@Configuration +@Getter +public class AmazonConfig { + + private AWSCredentials awsCredentials; + + @Value(("{cloud.aws.s3.bucket}")) + private String bucket; + + @Value("${cloud.aws.credentials.accessKey}") + private String accessKey; + + @Value("${cloud.aws.credentials.secretKey}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Value("${cloud.aws.s3.path.profile}") + private String profilePath; + + @PostConstruct + public void init() { + this.awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + } + + @Bean + public AmazonS3 amazonS3() { + AWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + return AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) + .build(); + } + + @Bean + public AWSCredentialsProvider awsCredentialsProvider() { + return new AWSStaticCredentialsProvider(awsCredentials); + } +} From fe24ea91a76d3ea2f21fc555e99f4c89e456b423 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 20 Jul 2025 22:35:01 +0900 Subject: [PATCH 205/339] =?UTF-8?q?[Feature]=20presignedUrl=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/aws/s3/AmazonS3Manager.java | 30 +++++++++ .../spring/config/AmazonConfig.java | 2 +- .../controller/ProfileImageController.java | 62 +++++++++++++++++++ .../spring/web/dto/s3/s3ResponseDTO.java | 14 +++++ 4 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 src/main/java/PerfumeOnMe/spring/web/controller/ProfileImageController.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/s3/s3ResponseDTO.java diff --git a/src/main/java/PerfumeOnMe/spring/aws/s3/AmazonS3Manager.java b/src/main/java/PerfumeOnMe/spring/aws/s3/AmazonS3Manager.java index 2d4c853..848e730 100644 --- a/src/main/java/PerfumeOnMe/spring/aws/s3/AmazonS3Manager.java +++ b/src/main/java/PerfumeOnMe/spring/aws/s3/AmazonS3Manager.java @@ -1,11 +1,15 @@ package PerfumeOnMe.spring.aws.s3; import java.io.IOException; +import java.net.URL; +import java.util.Date; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; +import com.amazonaws.HttpMethod; import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; import com.amazonaws.services.s3.model.ObjectMetadata; import com.amazonaws.services.s3.model.PutObjectRequest; @@ -42,4 +46,30 @@ public String uploadFile(String keyName, MultipartFile file) throws IOException return amazonS3.getUrl(amazonConfig.getBucket(), keyName).toString(); } + // Presigned URL 생성 + public URL generatePresignedUploadUrl(Uuid uuid, long expirationMillis, String fileExtension) { + String keyName = amazonConfig.getProfilePath() + "/" + uuid.getUuid() + "." + fileExtension; + + Date expiration = new Date(System.currentTimeMillis() + expirationMillis); + + GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest( + amazonConfig.getBucket(), keyName) + .withMethod(HttpMethod.PUT) + .withExpiration(expiration) + .withContentType("image/" + fileExtension); // 예: image/png + + return amazonS3.generatePresignedUrl(request); + } + + public String getBucket() { + return amazonConfig.getBucket(); + } + + public String getRegion() { + return amazonConfig.getRegion(); + } + + public String getProfilePath() { + return amazonConfig.getProfilePath(); + } } diff --git a/src/main/java/PerfumeOnMe/spring/config/AmazonConfig.java b/src/main/java/PerfumeOnMe/spring/config/AmazonConfig.java index 739f450..5421e55 100644 --- a/src/main/java/PerfumeOnMe/spring/config/AmazonConfig.java +++ b/src/main/java/PerfumeOnMe/spring/config/AmazonConfig.java @@ -20,7 +20,7 @@ public class AmazonConfig { private AWSCredentials awsCredentials; - @Value(("{cloud.aws.s3.bucket}")) + @Value("${cloud.aws.s3.bucket}") private String bucket; @Value("${cloud.aws.credentials.accessKey}") diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/ProfileImageController.java b/src/main/java/PerfumeOnMe/spring/web/controller/ProfileImageController.java new file mode 100644 index 0000000..1320764 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/controller/ProfileImageController.java @@ -0,0 +1,62 @@ +package PerfumeOnMe.spring.web.controller; + +import java.net.URL; +import java.util.UUID; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import PerfumeOnMe.spring.aws.s3.AmazonS3Manager; +import PerfumeOnMe.spring.domain.Uuid; +import PerfumeOnMe.spring.repository.uuid.UuidRepository; +import PerfumeOnMe.spring.web.dto.s3.s3ResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "S3 Presigned URL", description = "S3 프로필 이미지 업로드용 Presigned URL 발급 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/profile") +public class ProfileImageController { + + private final AmazonS3Manager amazonS3Manager; + private final UuidRepository uuidRepository; + + @Operation(summary = "Presigned URL 발급", description = "프로필 이미지 업로드를 위한 S3 Presigned URL을 생성합니다.") + @GetMapping("/upload-url") + public ResponseEntity generatePresignedUrl( + @Parameter(description = "파일 확장자 (예: png, jpg)", example = "png") + @RequestParam("ext") String ext // 확장자 입력 받음 + ) { + // 1. 고유 UUID 생성 및 DB에 저장 + Uuid uuid = uuidRepository.save(Uuid.builder() + .uuid(UUID.randomUUID().toString()) + .build()); + + // 2. Presigned URL 생성 (10분 유효) + long expirationMillis = 10 * 60 * 1000; + URL presignedUrl = amazonS3Manager.generatePresignedUploadUrl(uuid, expirationMillis, ext); + + // 3. 최종 업로드 완료 후 접근 가능한 S3 URL 생성 + // 3. 최종 S3 접근 URL 생성 + String s3Url = "https://" + amazonS3Manager.getBucket() // 버킷 이름 + + ".s3." + amazonS3Manager.getRegion() // 리전 + + ".amazonaws.com/" + amazonS3Manager.getProfilePath() // 경로 (예: user_profiles) + + "/" + uuid.getUuid() + "." + ext; // 파일명 + 확장자 + + // 4. 응답 객체 생성 + s3ResponseDTO.PresignedUrlResponseDTO response = s3ResponseDTO.PresignedUrlResponseDTO.builder() + .presignedUrl(presignedUrl.toString()) // PUT 요청할 presigned URL + .s3Url(s3Url) // 업로드된 이미지 접근용 URL + .uuid(uuid.getUuid()) // 추후 추적용 UUID + .build(); + + // 5. 응답 반환 + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/s3/s3ResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/s3/s3ResponseDTO.java new file mode 100644 index 0000000..d2a5316 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/s3/s3ResponseDTO.java @@ -0,0 +1,14 @@ +package PerfumeOnMe.spring.web.dto.s3; + +import lombok.Builder; +import lombok.Getter; + +public class s3ResponseDTO { + @Getter + @Builder + public static class PresignedUrlResponseDTO { + private final String presignedUrl; // PUT 요청용 URL- 프론트가 PUT 요청으로 업로드할 수 있는 주소 + private final String s3Url; // 최종 조회 가능한 URL - 업로드가 완료된 후 접근 가능한 이미지 URL + private final String uuid; // 내부 DB에 저장된 UUID + } +} From dc1143449f225d55c77deb8570721bd619494c7a Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 20 Jul 2025 23:47:31 +0900 Subject: [PATCH 206/339] =?UTF-8?q?[Feature]=20Converter,=EC=97=90?= =?UTF-8?q?=EB=9F=AC=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?http=20method=20=EB=B3=80=EA=B2=BD(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apiPayload/code/status/ErrorStatus.java | 3 + .../spring/converter/S3Converter.java | 16 ++++ .../controller/ProfileImageController.java | 62 ------------- .../spring/web/controller/S3Controller.java | 88 +++++++++++++++++++ .../spring/web/dto/s3/s3RequestDTO.java | 10 +++ 5 files changed, 117 insertions(+), 62 deletions(-) create mode 100644 src/main/java/PerfumeOnMe/spring/converter/S3Converter.java delete mode 100644 src/main/java/PerfumeOnMe/spring/web/controller/ProfileImageController.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/controller/S3Controller.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/s3/s3RequestDTO.java diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index bdf3bf1..757b4c3 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -72,6 +72,9 @@ public enum ErrorStatus implements BaseErrorCode { USER_DIARY_NOT_FOUND(HttpStatus.BAD_REQUEST, "DIARY4003", "해당 날짜에 해당하는 다이어리를 찾을 수 없습니다."), MONTH_DIARY_NOT_FOUND(HttpStatus.BAD_REQUEST, "DIARY4004", "해당 월에 작성된 다이어리가 없습니다."), + // S3 에러 + INVALID_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST, "S3IMAGE4001", "지원하지 않은 파일 확장자입니다."), + // 예시,,, ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); diff --git a/src/main/java/PerfumeOnMe/spring/converter/S3Converter.java b/src/main/java/PerfumeOnMe/spring/converter/S3Converter.java new file mode 100644 index 0000000..e4469a1 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/converter/S3Converter.java @@ -0,0 +1,16 @@ +package PerfumeOnMe.spring.converter; + +import PerfumeOnMe.spring.domain.Uuid; +import PerfumeOnMe.spring.web.dto.s3.s3ResponseDTO; + +public class S3Converter { + + public static s3ResponseDTO.PresignedUrlResponseDTO toPresignedUrlResponseDto(String presignedUrl, String s3Url, + Uuid uuid) { + return s3ResponseDTO.PresignedUrlResponseDTO.builder() + .presignedUrl(presignedUrl) + .s3Url(s3Url) + .uuid(uuid.getUuid()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/ProfileImageController.java b/src/main/java/PerfumeOnMe/spring/web/controller/ProfileImageController.java deleted file mode 100644 index 1320764..0000000 --- a/src/main/java/PerfumeOnMe/spring/web/controller/ProfileImageController.java +++ /dev/null @@ -1,62 +0,0 @@ -package PerfumeOnMe.spring.web.controller; - -import java.net.URL; -import java.util.UUID; - -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import PerfumeOnMe.spring.aws.s3.AmazonS3Manager; -import PerfumeOnMe.spring.domain.Uuid; -import PerfumeOnMe.spring.repository.uuid.UuidRepository; -import PerfumeOnMe.spring.web.dto.s3.s3ResponseDTO; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; - -@Tag(name = "S3 Presigned URL", description = "S3 프로필 이미지 업로드용 Presigned URL 발급 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/profile") -public class ProfileImageController { - - private final AmazonS3Manager amazonS3Manager; - private final UuidRepository uuidRepository; - - @Operation(summary = "Presigned URL 발급", description = "프로필 이미지 업로드를 위한 S3 Presigned URL을 생성합니다.") - @GetMapping("/upload-url") - public ResponseEntity generatePresignedUrl( - @Parameter(description = "파일 확장자 (예: png, jpg)", example = "png") - @RequestParam("ext") String ext // 확장자 입력 받음 - ) { - // 1. 고유 UUID 생성 및 DB에 저장 - Uuid uuid = uuidRepository.save(Uuid.builder() - .uuid(UUID.randomUUID().toString()) - .build()); - - // 2. Presigned URL 생성 (10분 유효) - long expirationMillis = 10 * 60 * 1000; - URL presignedUrl = amazonS3Manager.generatePresignedUploadUrl(uuid, expirationMillis, ext); - - // 3. 최종 업로드 완료 후 접근 가능한 S3 URL 생성 - // 3. 최종 S3 접근 URL 생성 - String s3Url = "https://" + amazonS3Manager.getBucket() // 버킷 이름 - + ".s3." + amazonS3Manager.getRegion() // 리전 - + ".amazonaws.com/" + amazonS3Manager.getProfilePath() // 경로 (예: user_profiles) - + "/" + uuid.getUuid() + "." + ext; // 파일명 + 확장자 - - // 4. 응답 객체 생성 - s3ResponseDTO.PresignedUrlResponseDTO response = s3ResponseDTO.PresignedUrlResponseDTO.builder() - .presignedUrl(presignedUrl.toString()) // PUT 요청할 presigned URL - .s3Url(s3Url) // 업로드된 이미지 접근용 URL - .uuid(uuid.getUuid()) // 추후 추적용 UUID - .build(); - - // 5. 응답 반환 - return ResponseEntity.ok(response); - } -} diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/S3Controller.java b/src/main/java/PerfumeOnMe/spring/web/controller/S3Controller.java new file mode 100644 index 0000000..16fd6c2 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/controller/S3Controller.java @@ -0,0 +1,88 @@ +package PerfumeOnMe.spring.web.controller; + +import java.net.URL; +import java.util.List; +import java.util.UUID; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.aws.s3.AmazonS3Manager; +import PerfumeOnMe.spring.converter.S3Converter; +import PerfumeOnMe.spring.domain.Uuid; +import PerfumeOnMe.spring.repository.uuid.UuidRepository; +import PerfumeOnMe.spring.web.dto.s3.s3RequestDTO; +import PerfumeOnMe.spring.web.dto.s3.s3ResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "S3 Presigned URL", description = "S3 프로필 이미지 업로드용 Presigned URL 발급 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/s3") +public class S3Controller { + + private final AmazonS3Manager amazonS3Manager; + private final UuidRepository uuidRepository; + + @PostMapping("/upload-url") + @Operation( + summary = "Presigned URL 발급", + description = "프로필 이미지 업로드를 위한 S3 Presigned URL을 생성합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON200", + description = "Presigned URL 발급 성공", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = s3ResponseDTO.PresignedUrlResponseDTO.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "S3IMAGE4001", + description = "지원하지 않은 파일 확장자입니다.", + content = @Content(mediaType = "application/json") + ) + } + ) + public ResponseEntity> generatePresignedUrl( + @RequestBody s3RequestDTO.PresignedUrlRequestDTO request // ✅ 요청 DTO 클래스를 요청 전용 클래스로 변경 + ) { + String fileName = request.getFileName(); + String ext = fileName.substring(fileName.lastIndexOf('.') + 1); // ✅ 파일명에서 확장자 추출 + + // 확장자 유효성 검사 + List allowedExtensions = List.of("png", "jpg", "jpeg", "webp"); + if (!allowedExtensions.contains(ext.toLowerCase())) { + throw new GeneralException(ErrorStatus.INVALID_IMAGE_EXTENSION); + } + + // UUID 생성 및 저장 + Uuid uuid = uuidRepository.save(Uuid.builder() + .uuid(UUID.randomUUID().toString()) + .build()); + + // Presigned URL 생성 + long expirationMillis = 10 * 60 * 1000; + URL presignedUrl = amazonS3Manager.generatePresignedUploadUrl(uuid, expirationMillis, ext); + + // S3 접근 URL 구성 + String s3Url = "https://" + amazonS3Manager.getBucket() + + ".s3." + amazonS3Manager.getRegion() + + ".amazonaws.com/" + amazonS3Manager.getProfilePath() + + "/" + uuid.getUuid() + "." + ext; + + // DTO 변환 및 응답 + s3ResponseDTO.PresignedUrlResponseDTO result = S3Converter.toPresignedUrlResponseDto( + presignedUrl.toString(), s3Url, uuid + ); + + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/s3/s3RequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/s3/s3RequestDTO.java new file mode 100644 index 0000000..10cd783 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/s3/s3RequestDTO.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.web.dto.s3; + +import lombok.Getter; + +public class s3RequestDTO { + @Getter + public static class PresignedUrlRequestDTO { + private String fileName; // e.g. "profile.png" + } +} From 8b9ff39bcc6828f95911db1056847452a95635af Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Mon, 21 Jul 2025 17:58:11 +0900 Subject: [PATCH 207/339] =?UTF-8?q?[Feature]=20user=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=EC=97=90=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=82=AC?= =?UTF-8?q?=EC=A7=84=20=EB=B3=80=EA=B2=BD=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80(#76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/PerfumeOnMe/spring/domain/User.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/domain/User.java b/src/main/java/PerfumeOnMe/spring/domain/User.java index 916fb83..8f78bf7 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/User.java +++ b/src/main/java/PerfumeOnMe/spring/domain/User.java @@ -118,4 +118,9 @@ public void addUserNote(UserNote userNote) { this.userNoteList.add(userNote); userNote.setUser(this); } + + // 프로필 사진 변경 메서드 + public void updateImageURL(String imageURL) { + this.imageURL = imageURL; + } } From 7e8f3e7b53ac08e50bde38b3af7387186e6b9cc3 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Mon, 21 Jul 2025 17:58:44 +0900 Subject: [PATCH 208/339] =?UTF-8?q?[Feature]=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20=EC=82=AC=EC=A7=84=20=EB=B3=80=EA=B2=BD=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=20DTO=20=EC=83=9D=EC=84=B1(#76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java index da88bc9..a83c4bd 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java @@ -77,4 +77,10 @@ public static class UserNoteUpdate { @ValidUserNote private List noteCategoryId; } + + // 프로필 사진 변경 + @Getter + public static class ProfileImageUpdateRequest { + private String imageUrl; // s3Url (업로드 완료된 S3 이미지 주소) + } } From 53435edb662a9c7fe2bde52f240546097d581cc3 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Mon, 21 Jul 2025 17:58:55 +0900 Subject: [PATCH 209/339] =?UTF-8?q?[Feature]=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20=EC=82=AC=EC=A7=84=20=EB=B3=80=EA=B2=BD=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84(#76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/service/user/UserService.java | 2 ++ .../spring/service/user/UserServiceImpl.java | 9 +++++++++ .../spring/web/controller/UserController.java | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserService.java b/src/main/java/PerfumeOnMe/spring/service/user/UserService.java index 61f9777..e70e1d8 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserService.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserService.java @@ -27,4 +27,6 @@ public interface UserService { FragranceResponseDTO.FragranceSearchFinalResult getFavoriteFragrances( FragranceRequestDTO.FragranceAllRequest request, Long userId); + + void updateProfileImage(Long userId, String imageUrl); } diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java index 923ff98..0c94b15 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java @@ -204,6 +204,15 @@ public UserResponseDTO.MyPageProfileResponse getUserProfile(Long userId) { return UserConverter.toMyPageProfileResponse(user); } + // 마이페이지 프로필 사진 변경 + @Override + public void updateProfileImage(Long userId, String imageUrl) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + user.updateImageURL(imageUrl); + userRepository.save(user); + } + // 마이페이지 즐겨찾기 목록 조회 @Override public FragranceResponseDTO.FragranceSearchFinalResult getFavoriteFragrances( diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java index 19bebf8..1ff3371 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java @@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; @@ -166,4 +167,21 @@ public ResponseEntity> updateProfileImage( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody @Valid UserRequestDTO.ProfileImageUpdateRequest request) { + + userService.updateProfileImage(userDetails.getUserId(), request.getImageUrl()); + return ResponseEntity.ok(ApiResponse.onSuccess(null)); + } } From 9a40a60a039c693b5371cf84b287d7165a9d92e3 Mon Sep 17 00:00:00 2001 From: bulee5328 Date: Fri, 18 Jul 2025 23:20:14 +0900 Subject: [PATCH 210/339] =?UTF-8?q?[Feature]=20=EC=9D=BC=EB=B3=84=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=B4=EB=A6=AC=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apiPayload/code/status/ErrorStatus.java | 1 + .../repository/diary/DiaryRepository.java | 4 +++ .../spring/service/Diary/DiaryService.java | 6 ++++ .../service/Diary/DiaryServiceImpl.java | 18 ++++++++++++ .../web/controller/DiaryController.java | 26 +++++++++++++++++ .../web/dto/diary/DiaryResponseDTO.java | 28 +++++++++++++++++++ 6 files changed, 83 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index 6420fed..d141a85 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -69,6 +69,7 @@ public enum ErrorStatus implements BaseErrorCode { // 다이어리 에러 DIARY_NOT_FOUND(HttpStatus.BAD_REQUEST, "DIARY4001", "해당 다이어리를 찾을 수 없습니다."), USER_DIARY_FORBIDDEN(HttpStatus.BAD_REQUEST, "DIARY4002", "다이어리 소유자의 요청이 아닙니다."), + USER_DIARY_NOT_FOUND(HttpStatus.BAD_REQUEST, "DIARY4003", "해당 날짜에 해당하는 다이어리를 찾을 수 없습니다."), // 예시,,, ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); diff --git a/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepository.java b/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepository.java index 2a18552..6cd58aa 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepository.java @@ -1,5 +1,7 @@ package PerfumeOnMe.spring.repository.diary; +import java.time.LocalDate; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,4 +11,6 @@ public interface DiaryRepository extends JpaRepository, DiaryRepositoryCustom { Optional findById(Long id); + + List findAllByUserIdAndDate(Long userId, LocalDate date); } diff --git a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java index dcceeb6..bae7332 100644 --- a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java +++ b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java @@ -1,5 +1,8 @@ package PerfumeOnMe.spring.service.Diary; +import java.time.LocalDate; +import java.util.List; + import PerfumeOnMe.spring.web.dto.diary.DiaryRequestDTO; import PerfumeOnMe.spring.web.dto.diary.DiaryResponseDTO; @@ -13,4 +16,7 @@ public interface DiaryService { // 다이어리 삭제 API void deleteDiary(Long userId, Long diaryId); + + // 일별 다이어리 상세 조회 API + List searchDailyDiary(Long userId, LocalDate date); } diff --git a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java index f72133d..3950b9b 100644 --- a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java @@ -1,5 +1,8 @@ package PerfumeOnMe.spring.service.Diary; +import java.time.LocalDate; +import java.util.List; + import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -71,4 +74,19 @@ public void deleteDiary(Long userId, Long diaryId) { diaryRepository.delete(diary); } + + // 일별 다이어리 상세 조회 API + @Override + public List searchDailyDiary(Long userId, LocalDate date) { + // 해당 날짜의 다이어리들 조회 + List diaries = diaryRepository.findAllByUserIdAndDate(userId, date); + + if (diaries.isEmpty()) { + throw new GeneralException(ErrorStatus.USER_DIARY_NOT_FOUND); + } + + return DiaryResponseDTO.SearchDailyDiaryResponse.fromEntityList(diaries); + } } + + diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java b/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java index decca9b..6cc8097 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java @@ -1,8 +1,12 @@ package PerfumeOnMe.spring.web.controller; +import java.time.LocalDate; +import java.util.List; + import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -17,6 +21,7 @@ import PerfumeOnMe.spring.web.dto.diary.DiaryRequestDTO; import PerfumeOnMe.spring.web.dto.diary.DiaryResponseDTO; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; @@ -84,4 +89,25 @@ public ResponseEntity> deleteDiary( diaryService.deleteDiary(userDetails.getUserId(), diaryId); return ResponseEntity.ok(ApiResponse.of(SuccessStatus.DIARY_DELETED, null)); } + + // 일별 다이어리 상세 조회 API + @GetMapping("/daily/{date}") + @Operation( + summary = "일별 다이어리 상세 조회", + description = "일별 다이어리를 상세 조회하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "다이어리가 조회되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = DiaryResponseDTO.SearchDailyDiaryResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "DIARY4003", description = "해당 날짜에 해당하는 다이어리를 찾을 수 없습니다.") + } + ) + public ResponseEntity>> searchDailyDiary( + @Parameter( + description = "조회할 날짜 (예: 2025-07-17)" + ) + @PathVariable LocalDate date, + @AuthenticationPrincipal CustomUserDetails userDetails) { + List result = diaryService.searchDailyDiary(userDetails.getUserId(), + date); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java index ccff2b2..ef6af5d 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java @@ -1,7 +1,10 @@ package PerfumeOnMe.spring.web.dto.diary; import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import PerfumeOnMe.spring.domain.mapping.Diary; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -19,4 +22,29 @@ public static class AddDiaryResponse { private LocalDate date; } + // 일별 다이어리 상세 조회 응답 DTO + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class SearchDailyDiaryResponse { + private Long id; + private LocalDate date; + private String content; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static List fromEntityList(List diaries) { + return diaries.stream() + .map(diary -> SearchDailyDiaryResponse.builder() + .id(diary.getId()) + .date(diary.getDate()) + .content(diary.getContent()) + .createdAt(diary.getCreatedAt()) + .updatedAt(diary.getUpdatedAt()) + .build()) + .toList(); + } + } + } From 54d01de89821a65e004d191fe7298f9ee918672d Mon Sep 17 00:00:00 2001 From: bulee5328 Date: Sat, 19 Jul 2025 12:31:04 +0900 Subject: [PATCH 211/339] =?UTF-8?q?[Feature]=20=EC=9D=91=EB=8B=B5=20DTO=20?= =?UTF-8?q?=C3=A3fragranceName=20=EC=B6=94=EA=B0=80=20(#69)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java index ef6af5d..eee7b23 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java @@ -29,6 +29,7 @@ public static class AddDiaryResponse { @NoArgsConstructor public static class SearchDailyDiaryResponse { private Long id; + private String fragranceName; private LocalDate date; private String content; private LocalDateTime createdAt; @@ -38,6 +39,7 @@ public static List fromEntityList(List diaries) return diaries.stream() .map(diary -> SearchDailyDiaryResponse.builder() .id(diary.getId()) + .fragranceName(diary.getFragranceName()) .date(diary.getDate()) .content(diary.getContent()) .createdAt(diary.getCreatedAt()) From 2e52f50474a854d18d43c169ea6b0104fd1797f7 Mon Sep 17 00:00:00 2001 From: bulee5328 Date: Sat, 19 Jul 2025 13:26:54 +0900 Subject: [PATCH 212/339] =?UTF-8?q?[Feature]=20=EC=9B=94=EB=B3=84=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=B4=EC=96=B4=EB=A6=AC=20=EC=A1=B0=ED=9A=8C=20API?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(#71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apiPayload/code/status/ErrorStatus.java | 1 + .../repository/diary/DiaryRepository.java | 2 ++ .../spring/service/Diary/DiaryService.java | 4 ++++ .../service/Diary/DiaryServiceImpl.java | 14 +++++++++++ .../web/controller/DiaryController.java | 23 +++++++++++++++++++ .../web/dto/diary/DiaryResponseDTO.java | 23 +++++++++++++++++++ 6 files changed, 67 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index d141a85..bdf3bf1 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -70,6 +70,7 @@ public enum ErrorStatus implements BaseErrorCode { DIARY_NOT_FOUND(HttpStatus.BAD_REQUEST, "DIARY4001", "해당 다이어리를 찾을 수 없습니다."), USER_DIARY_FORBIDDEN(HttpStatus.BAD_REQUEST, "DIARY4002", "다이어리 소유자의 요청이 아닙니다."), USER_DIARY_NOT_FOUND(HttpStatus.BAD_REQUEST, "DIARY4003", "해당 날짜에 해당하는 다이어리를 찾을 수 없습니다."), + MONTH_DIARY_NOT_FOUND(HttpStatus.BAD_REQUEST, "DIARY4004", "해당 월에 작성된 다이어리가 없습니다."), // 예시,,, ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); diff --git a/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepository.java b/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepository.java index 6cd58aa..9756473 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepository.java @@ -13,4 +13,6 @@ public interface DiaryRepository extends JpaRepository, DiaryReposi Optional findById(Long id); List findAllByUserIdAndDate(Long userId, LocalDate date); + + List findAllByUserIdAndDateBetween(Long userId, LocalDate startDate, LocalDate endDate); } diff --git a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java index bae7332..b162e81 100644 --- a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java +++ b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java @@ -19,4 +19,8 @@ public interface DiaryService { // 일별 다이어리 상세 조회 API List searchDailyDiary(Long userId, LocalDate date); + + // 월별 다이어리 조회 API + List searchMonthlyDiary(Long userId, LocalDate startDate, + LocalDate endDate); } diff --git a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java index 3950b9b..d8bf991 100644 --- a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java @@ -87,6 +87,20 @@ public List searchDailyDiary(Long use return DiaryResponseDTO.SearchDailyDiaryResponse.fromEntityList(diaries); } + + // 월별 다이어리 조회 API + @Override + public List searchMonthlyDiary(Long userId, LocalDate startDate, + LocalDate endDate) { + // 해당 월의 다이어리들 조회 + List diaries = diaryRepository.findAllByUserIdAndDateBetween(userId, startDate, endDate); + + if (diaries.isEmpty()) { + throw new GeneralException(ErrorStatus.MONTH_DIARY_NOT_FOUND); + } + + return DiaryResponseDTO.SearchMonthlyDiaryResponse.fromEntityList(diaries); + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java b/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java index 6cc8097..323fb2d 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java @@ -110,4 +110,27 @@ public ResponseEntity>> searchMonthlyDiary( + @PathVariable Integer year, + @PathVariable Integer month, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + LocalDate startDate = LocalDate.of(year, month, 1); + LocalDate endDate = startDate.withDayOfMonth(startDate.lengthOfMonth()); + + List result = + diaryService.searchMonthlyDiary(userDetails.getUserId(), startDate, endDate); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java index eee7b23..e184043 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java @@ -49,4 +49,27 @@ public static List fromEntityList(List diaries) } } + // 월별 다이어리 조회 응답 DTO + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class SearchMonthlyDiaryResponse { + private Long id; + private String fragranceName; + private LocalDate date; + private String content; + + public static List fromEntityList(List diaries) { + return diaries.stream() + .map(diary -> SearchMonthlyDiaryResponse.builder() + .id(diary.getId()) + .fragranceName(diary.getFragranceName()) + .date(diary.getDate()) + .content(diary.getContent()) + .build()) + .toList(); + } + } + } From 4ee946e90ba59759e5f2e84b1caf3aefeba1492c Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 20 Jul 2025 20:32:54 +0900 Subject: [PATCH 213/339] =?UTF-8?q?[Feature]=20build.gradle=20s3=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle b/build.gradle index 6414f32..c4c9b8a 100644 --- a/build.gradle +++ b/build.gradle @@ -79,6 +79,8 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-webflux' // OAuth2.0 implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + //S3 + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' } tasks.named('test') { From 47e636e822b4475f7681f9036a5e43db88cff6f7 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 20 Jul 2025 21:53:03 +0900 Subject: [PATCH 214/339] =?UTF-8?q?[Feature]=20application-dev=20s3?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 29c071d..06de960 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -56,4 +56,18 @@ server: enabled: true openai: api-key: ${OPENAI_API_KEY} - model: gpt-4 \ No newline at end of file + model: gpt-4 + +cloud: + aws: + s3: + bucket: umc-perfume-bucket + path: + profile: user_profiles + region: + static: ap-northeast-1 + stack: + auto: false + credentials: + accessKey: ${AWS_S3_ACCESS_KEY_ID} + secretKey: ${AWS_S3_SECRET_ACCESS_KEY} \ No newline at end of file From 194d72967aacf091aa6b1ce549162fd5c2b298d6 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 20 Jul 2025 21:54:08 +0900 Subject: [PATCH 215/339] =?UTF-8?q?[Feature]=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EA=B3=A0=EC=9C=A0=EC=84=B1=EC=9D=84=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?UUID=EC=B6=94=EA=B0=80(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/PerfumeOnMe/spring/domain/Uuid.java | 27 +++++++++++++++++++ .../repository/uuid/UuidRepository.java | 11 ++++++++ 2 files changed, 38 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/domain/Uuid.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/uuid/UuidRepository.java diff --git a/src/main/java/PerfumeOnMe/spring/domain/Uuid.java b/src/main/java/PerfumeOnMe/spring/domain/Uuid.java new file mode 100644 index 0000000..bcc6177 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/Uuid.java @@ -0,0 +1,27 @@ +package PerfumeOnMe.spring.domain; + +import PerfumeOnMe.spring.domain.base.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Builder +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class Uuid extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true) + private String uuid; +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/uuid/UuidRepository.java b/src/main/java/PerfumeOnMe/spring/repository/uuid/UuidRepository.java new file mode 100644 index 0000000..4beeab2 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/uuid/UuidRepository.java @@ -0,0 +1,11 @@ +package PerfumeOnMe.spring.repository.uuid; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.domain.Uuid; + +public interface UuidRepository extends JpaRepository { + Optional findByUuid(String uuid); +} From 5d33101354cfd09b4911fb588df3809ad4424f18 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 20 Jul 2025 21:54:55 +0900 Subject: [PATCH 216/339] =?UTF-8?q?[Feature]=20Config,=20Manager=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/aws/s3/AmazonS3Manager.java | 45 +++++++++++++++ .../spring/config/AmazonConfig.java | 56 +++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/aws/s3/AmazonS3Manager.java create mode 100644 src/main/java/PerfumeOnMe/spring/config/AmazonConfig.java diff --git a/src/main/java/PerfumeOnMe/spring/aws/s3/AmazonS3Manager.java b/src/main/java/PerfumeOnMe/spring/aws/s3/AmazonS3Manager.java new file mode 100644 index 0000000..2d4c853 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/aws/s3/AmazonS3Manager.java @@ -0,0 +1,45 @@ +package PerfumeOnMe.spring.aws.s3; + +import java.io.IOException; + +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; + +import PerfumeOnMe.spring.config.AmazonConfig; +import PerfumeOnMe.spring.domain.Uuid; +import PerfumeOnMe.spring.repository.uuid.UuidRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AmazonS3Manager { + + private final AmazonS3 amazonS3; + + private final AmazonConfig amazonConfig; + + private final UuidRepository uuidRepository; + + // S3에 저장할 경로(prefix + 파일이름) 생성 + public String generateProfileKeyName(Uuid uuid) { + return amazonConfig.getProfilePath() + '/' + uuid.getUuid(); + } + + // MultipartFile로 받은 파일을 S3버의 keyname 경로에 저장 + // 저장 후 URL반환 + public String uploadFile(String keyName, MultipartFile file) throws IOException { + System.out.println(keyName); + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + amazonS3.putObject(new PutObjectRequest(amazonConfig.getBucket(), keyName, file.getInputStream(), metadata)); + + return amazonS3.getUrl(amazonConfig.getBucket(), keyName).toString(); + } + +} diff --git a/src/main/java/PerfumeOnMe/spring/config/AmazonConfig.java b/src/main/java/PerfumeOnMe/spring/config/AmazonConfig.java new file mode 100644 index 0000000..739f450 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/AmazonConfig.java @@ -0,0 +1,56 @@ +package PerfumeOnMe.spring.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSCredentialsProvider; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; + +import jakarta.annotation.PostConstruct; +import lombok.Getter; + +@Configuration +@Getter +public class AmazonConfig { + + private AWSCredentials awsCredentials; + + @Value(("{cloud.aws.s3.bucket}")) + private String bucket; + + @Value("${cloud.aws.credentials.accessKey}") + private String accessKey; + + @Value("${cloud.aws.credentials.secretKey}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Value("${cloud.aws.s3.path.profile}") + private String profilePath; + + @PostConstruct + public void init() { + this.awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + } + + @Bean + public AmazonS3 amazonS3() { + AWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + return AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) + .build(); + } + + @Bean + public AWSCredentialsProvider awsCredentialsProvider() { + return new AWSStaticCredentialsProvider(awsCredentials); + } +} From f850f14ef7609e983ddb093d652c851b6856bbd5 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 20 Jul 2025 22:35:01 +0900 Subject: [PATCH 217/339] =?UTF-8?q?[Feature]=20presignedUrl=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/aws/s3/AmazonS3Manager.java | 30 +++++++++ .../spring/config/AmazonConfig.java | 2 +- .../controller/ProfileImageController.java | 62 +++++++++++++++++++ .../spring/web/dto/s3/s3ResponseDTO.java | 14 +++++ 4 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 src/main/java/PerfumeOnMe/spring/web/controller/ProfileImageController.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/s3/s3ResponseDTO.java diff --git a/src/main/java/PerfumeOnMe/spring/aws/s3/AmazonS3Manager.java b/src/main/java/PerfumeOnMe/spring/aws/s3/AmazonS3Manager.java index 2d4c853..848e730 100644 --- a/src/main/java/PerfumeOnMe/spring/aws/s3/AmazonS3Manager.java +++ b/src/main/java/PerfumeOnMe/spring/aws/s3/AmazonS3Manager.java @@ -1,11 +1,15 @@ package PerfumeOnMe.spring.aws.s3; import java.io.IOException; +import java.net.URL; +import java.util.Date; import org.springframework.stereotype.Component; import org.springframework.web.multipart.MultipartFile; +import com.amazonaws.HttpMethod; import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; import com.amazonaws.services.s3.model.ObjectMetadata; import com.amazonaws.services.s3.model.PutObjectRequest; @@ -42,4 +46,30 @@ public String uploadFile(String keyName, MultipartFile file) throws IOException return amazonS3.getUrl(amazonConfig.getBucket(), keyName).toString(); } + // Presigned URL 생성 + public URL generatePresignedUploadUrl(Uuid uuid, long expirationMillis, String fileExtension) { + String keyName = amazonConfig.getProfilePath() + "/" + uuid.getUuid() + "." + fileExtension; + + Date expiration = new Date(System.currentTimeMillis() + expirationMillis); + + GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest( + amazonConfig.getBucket(), keyName) + .withMethod(HttpMethod.PUT) + .withExpiration(expiration) + .withContentType("image/" + fileExtension); // 예: image/png + + return amazonS3.generatePresignedUrl(request); + } + + public String getBucket() { + return amazonConfig.getBucket(); + } + + public String getRegion() { + return amazonConfig.getRegion(); + } + + public String getProfilePath() { + return amazonConfig.getProfilePath(); + } } diff --git a/src/main/java/PerfumeOnMe/spring/config/AmazonConfig.java b/src/main/java/PerfumeOnMe/spring/config/AmazonConfig.java index 739f450..5421e55 100644 --- a/src/main/java/PerfumeOnMe/spring/config/AmazonConfig.java +++ b/src/main/java/PerfumeOnMe/spring/config/AmazonConfig.java @@ -20,7 +20,7 @@ public class AmazonConfig { private AWSCredentials awsCredentials; - @Value(("{cloud.aws.s3.bucket}")) + @Value("${cloud.aws.s3.bucket}") private String bucket; @Value("${cloud.aws.credentials.accessKey}") diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/ProfileImageController.java b/src/main/java/PerfumeOnMe/spring/web/controller/ProfileImageController.java new file mode 100644 index 0000000..1320764 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/controller/ProfileImageController.java @@ -0,0 +1,62 @@ +package PerfumeOnMe.spring.web.controller; + +import java.net.URL; +import java.util.UUID; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import PerfumeOnMe.spring.aws.s3.AmazonS3Manager; +import PerfumeOnMe.spring.domain.Uuid; +import PerfumeOnMe.spring.repository.uuid.UuidRepository; +import PerfumeOnMe.spring.web.dto.s3.s3ResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "S3 Presigned URL", description = "S3 프로필 이미지 업로드용 Presigned URL 발급 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/profile") +public class ProfileImageController { + + private final AmazonS3Manager amazonS3Manager; + private final UuidRepository uuidRepository; + + @Operation(summary = "Presigned URL 발급", description = "프로필 이미지 업로드를 위한 S3 Presigned URL을 생성합니다.") + @GetMapping("/upload-url") + public ResponseEntity generatePresignedUrl( + @Parameter(description = "파일 확장자 (예: png, jpg)", example = "png") + @RequestParam("ext") String ext // 확장자 입력 받음 + ) { + // 1. 고유 UUID 생성 및 DB에 저장 + Uuid uuid = uuidRepository.save(Uuid.builder() + .uuid(UUID.randomUUID().toString()) + .build()); + + // 2. Presigned URL 생성 (10분 유효) + long expirationMillis = 10 * 60 * 1000; + URL presignedUrl = amazonS3Manager.generatePresignedUploadUrl(uuid, expirationMillis, ext); + + // 3. 최종 업로드 완료 후 접근 가능한 S3 URL 생성 + // 3. 최종 S3 접근 URL 생성 + String s3Url = "https://" + amazonS3Manager.getBucket() // 버킷 이름 + + ".s3." + amazonS3Manager.getRegion() // 리전 + + ".amazonaws.com/" + amazonS3Manager.getProfilePath() // 경로 (예: user_profiles) + + "/" + uuid.getUuid() + "." + ext; // 파일명 + 확장자 + + // 4. 응답 객체 생성 + s3ResponseDTO.PresignedUrlResponseDTO response = s3ResponseDTO.PresignedUrlResponseDTO.builder() + .presignedUrl(presignedUrl.toString()) // PUT 요청할 presigned URL + .s3Url(s3Url) // 업로드된 이미지 접근용 URL + .uuid(uuid.getUuid()) // 추후 추적용 UUID + .build(); + + // 5. 응답 반환 + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/s3/s3ResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/s3/s3ResponseDTO.java new file mode 100644 index 0000000..d2a5316 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/s3/s3ResponseDTO.java @@ -0,0 +1,14 @@ +package PerfumeOnMe.spring.web.dto.s3; + +import lombok.Builder; +import lombok.Getter; + +public class s3ResponseDTO { + @Getter + @Builder + public static class PresignedUrlResponseDTO { + private final String presignedUrl; // PUT 요청용 URL- 프론트가 PUT 요청으로 업로드할 수 있는 주소 + private final String s3Url; // 최종 조회 가능한 URL - 업로드가 완료된 후 접근 가능한 이미지 URL + private final String uuid; // 내부 DB에 저장된 UUID + } +} From 1cb2c32ba2a60aba141ea7b4322e4c00459e4d95 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 20 Jul 2025 23:47:31 +0900 Subject: [PATCH 218/339] =?UTF-8?q?[Feature]=20Converter,=EC=97=90?= =?UTF-8?q?=EB=9F=AC=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?http=20method=20=EB=B3=80=EA=B2=BD(#74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apiPayload/code/status/ErrorStatus.java | 3 + .../spring/converter/S3Converter.java | 16 ++++ .../controller/ProfileImageController.java | 62 ------------- .../spring/web/controller/S3Controller.java | 88 +++++++++++++++++++ .../spring/web/dto/s3/s3RequestDTO.java | 10 +++ 5 files changed, 117 insertions(+), 62 deletions(-) create mode 100644 src/main/java/PerfumeOnMe/spring/converter/S3Converter.java delete mode 100644 src/main/java/PerfumeOnMe/spring/web/controller/ProfileImageController.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/controller/S3Controller.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/s3/s3RequestDTO.java diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index bdf3bf1..757b4c3 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -72,6 +72,9 @@ public enum ErrorStatus implements BaseErrorCode { USER_DIARY_NOT_FOUND(HttpStatus.BAD_REQUEST, "DIARY4003", "해당 날짜에 해당하는 다이어리를 찾을 수 없습니다."), MONTH_DIARY_NOT_FOUND(HttpStatus.BAD_REQUEST, "DIARY4004", "해당 월에 작성된 다이어리가 없습니다."), + // S3 에러 + INVALID_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST, "S3IMAGE4001", "지원하지 않은 파일 확장자입니다."), + // 예시,,, ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); diff --git a/src/main/java/PerfumeOnMe/spring/converter/S3Converter.java b/src/main/java/PerfumeOnMe/spring/converter/S3Converter.java new file mode 100644 index 0000000..e4469a1 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/converter/S3Converter.java @@ -0,0 +1,16 @@ +package PerfumeOnMe.spring.converter; + +import PerfumeOnMe.spring.domain.Uuid; +import PerfumeOnMe.spring.web.dto.s3.s3ResponseDTO; + +public class S3Converter { + + public static s3ResponseDTO.PresignedUrlResponseDTO toPresignedUrlResponseDto(String presignedUrl, String s3Url, + Uuid uuid) { + return s3ResponseDTO.PresignedUrlResponseDTO.builder() + .presignedUrl(presignedUrl) + .s3Url(s3Url) + .uuid(uuid.getUuid()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/ProfileImageController.java b/src/main/java/PerfumeOnMe/spring/web/controller/ProfileImageController.java deleted file mode 100644 index 1320764..0000000 --- a/src/main/java/PerfumeOnMe/spring/web/controller/ProfileImageController.java +++ /dev/null @@ -1,62 +0,0 @@ -package PerfumeOnMe.spring.web.controller; - -import java.net.URL; -import java.util.UUID; - -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import PerfumeOnMe.spring.aws.s3.AmazonS3Manager; -import PerfumeOnMe.spring.domain.Uuid; -import PerfumeOnMe.spring.repository.uuid.UuidRepository; -import PerfumeOnMe.spring.web.dto.s3.s3ResponseDTO; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.RequiredArgsConstructor; - -@Tag(name = "S3 Presigned URL", description = "S3 프로필 이미지 업로드용 Presigned URL 발급 API") -@RestController -@RequiredArgsConstructor -@RequestMapping("/api/v1/profile") -public class ProfileImageController { - - private final AmazonS3Manager amazonS3Manager; - private final UuidRepository uuidRepository; - - @Operation(summary = "Presigned URL 발급", description = "프로필 이미지 업로드를 위한 S3 Presigned URL을 생성합니다.") - @GetMapping("/upload-url") - public ResponseEntity generatePresignedUrl( - @Parameter(description = "파일 확장자 (예: png, jpg)", example = "png") - @RequestParam("ext") String ext // 확장자 입력 받음 - ) { - // 1. 고유 UUID 생성 및 DB에 저장 - Uuid uuid = uuidRepository.save(Uuid.builder() - .uuid(UUID.randomUUID().toString()) - .build()); - - // 2. Presigned URL 생성 (10분 유효) - long expirationMillis = 10 * 60 * 1000; - URL presignedUrl = amazonS3Manager.generatePresignedUploadUrl(uuid, expirationMillis, ext); - - // 3. 최종 업로드 완료 후 접근 가능한 S3 URL 생성 - // 3. 최종 S3 접근 URL 생성 - String s3Url = "https://" + amazonS3Manager.getBucket() // 버킷 이름 - + ".s3." + amazonS3Manager.getRegion() // 리전 - + ".amazonaws.com/" + amazonS3Manager.getProfilePath() // 경로 (예: user_profiles) - + "/" + uuid.getUuid() + "." + ext; // 파일명 + 확장자 - - // 4. 응답 객체 생성 - s3ResponseDTO.PresignedUrlResponseDTO response = s3ResponseDTO.PresignedUrlResponseDTO.builder() - .presignedUrl(presignedUrl.toString()) // PUT 요청할 presigned URL - .s3Url(s3Url) // 업로드된 이미지 접근용 URL - .uuid(uuid.getUuid()) // 추후 추적용 UUID - .build(); - - // 5. 응답 반환 - return ResponseEntity.ok(response); - } -} diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/S3Controller.java b/src/main/java/PerfumeOnMe/spring/web/controller/S3Controller.java new file mode 100644 index 0000000..16fd6c2 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/controller/S3Controller.java @@ -0,0 +1,88 @@ +package PerfumeOnMe.spring.web.controller; + +import java.net.URL; +import java.util.List; +import java.util.UUID; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.aws.s3.AmazonS3Manager; +import PerfumeOnMe.spring.converter.S3Converter; +import PerfumeOnMe.spring.domain.Uuid; +import PerfumeOnMe.spring.repository.uuid.UuidRepository; +import PerfumeOnMe.spring.web.dto.s3.s3RequestDTO; +import PerfumeOnMe.spring.web.dto.s3.s3ResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "S3 Presigned URL", description = "S3 프로필 이미지 업로드용 Presigned URL 발급 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/s3") +public class S3Controller { + + private final AmazonS3Manager amazonS3Manager; + private final UuidRepository uuidRepository; + + @PostMapping("/upload-url") + @Operation( + summary = "Presigned URL 발급", + description = "프로필 이미지 업로드를 위한 S3 Presigned URL을 생성합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON200", + description = "Presigned URL 발급 성공", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = s3ResponseDTO.PresignedUrlResponseDTO.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "S3IMAGE4001", + description = "지원하지 않은 파일 확장자입니다.", + content = @Content(mediaType = "application/json") + ) + } + ) + public ResponseEntity> generatePresignedUrl( + @RequestBody s3RequestDTO.PresignedUrlRequestDTO request // ✅ 요청 DTO 클래스를 요청 전용 클래스로 변경 + ) { + String fileName = request.getFileName(); + String ext = fileName.substring(fileName.lastIndexOf('.') + 1); // ✅ 파일명에서 확장자 추출 + + // 확장자 유효성 검사 + List allowedExtensions = List.of("png", "jpg", "jpeg", "webp"); + if (!allowedExtensions.contains(ext.toLowerCase())) { + throw new GeneralException(ErrorStatus.INVALID_IMAGE_EXTENSION); + } + + // UUID 생성 및 저장 + Uuid uuid = uuidRepository.save(Uuid.builder() + .uuid(UUID.randomUUID().toString()) + .build()); + + // Presigned URL 생성 + long expirationMillis = 10 * 60 * 1000; + URL presignedUrl = amazonS3Manager.generatePresignedUploadUrl(uuid, expirationMillis, ext); + + // S3 접근 URL 구성 + String s3Url = "https://" + amazonS3Manager.getBucket() + + ".s3." + amazonS3Manager.getRegion() + + ".amazonaws.com/" + amazonS3Manager.getProfilePath() + + "/" + uuid.getUuid() + "." + ext; + + // DTO 변환 및 응답 + s3ResponseDTO.PresignedUrlResponseDTO result = S3Converter.toPresignedUrlResponseDto( + presignedUrl.toString(), s3Url, uuid + ); + + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/s3/s3RequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/s3/s3RequestDTO.java new file mode 100644 index 0000000..10cd783 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/s3/s3RequestDTO.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.web.dto.s3; + +import lombok.Getter; + +public class s3RequestDTO { + @Getter + public static class PresignedUrlRequestDTO { + private String fileName; // e.g. "profile.png" + } +} From bb1daa41e465152bf6093f76a7407353c4877781 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 21 Jul 2025 17:44:33 +0900 Subject: [PATCH 219/339] =?UTF-8?q?[Feature]=20=EB=A9=94=EC=9D=B8=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=ED=96=A5=EC=88=98=20=EC=B6=94=EC=B2=9C(MD?= =?UTF-8?q?'s=20Choice)=20API=20=EA=B5=AC=ED=98=84=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/converter/FragranceConverter.java | 8 +++++ .../fragrance/FragranceRepositoryCustom.java | 5 +++ .../fragrance/FragranceRepositoryImpl.java | 32 +++++++++++++++++ .../service/fragrance/FragranceService.java | 3 ++ .../fragrance/FragranceServiceImpl.java | 34 +++++++++++++++++++ .../web/controller/FragranceController.java | 14 ++++++++ .../dto/fragrance/FragranceResponseDTO.java | 9 +++++ 7 files changed, 105 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java index f431379..9fb7155 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java @@ -144,6 +144,14 @@ public static FragranceResponseDTO.FragranceSearchFinalResult toSearchFinalResul .build(); } + // 마이페이지 향수 추천(Md's Choice) 목록 반환 + public static FragranceResponseDTO.FragranceMdChoiceResult toMdChoiceResult( + List content) { + return FragranceResponseDTO.FragranceMdChoiceResult.builder() + .content(content) + .build(); + } + // 향수 즐겨찾기 등록 API public static FragranceResponseDTO.FavoriteResponseDTO toFavoriteResponseDTO(UserFragrance userFragrance) { return FragranceResponseDTO.FavoriteResponseDTO.builder() diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java index 9e6c51c..35342fb 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java @@ -1,11 +1,13 @@ package PerfumeOnMe.spring.repository.fragrance; +import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import PerfumeOnMe.spring.domain.Fragrance; +import PerfumeOnMe.spring.domain.enums.FragranceGender; import PerfumeOnMe.spring.web.dto.fragrance.FragranceRequestDTO; public interface FragranceRepositoryCustom { @@ -18,4 +20,7 @@ public interface FragranceRepositoryCustom { // 향수 필터링 Page findByFilter(FragranceRequestDTO.FragranceFilterRequest request, Pageable pageable); + // 메인페이지 향수 추천(Md's Choice) + List findByUserMdChoice(FragranceGender gender, List userNoteIdList); + } diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java index 4971400..998a26d 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java @@ -11,6 +11,8 @@ import com.querydsl.core.BooleanBuilder; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.core.types.dsl.NumberExpression; import com.querydsl.core.types.dsl.StringPath; import com.querydsl.jpa.impl.JPAQueryFactory; @@ -153,4 +155,34 @@ public Page findByFilter(FragranceRequestDTO.FragranceFilterRequest r return new PageImpl<>(result, pageable, total != null ? total : 0); } + + @Override + public List findByUserMdChoice(FragranceGender gender, List userNoteIdList) { + + BooleanBuilder predicate = new BooleanBuilder(); + predicate.and(f.gender.eq(gender)); + + NumberExpression topCount = ftn.note.id.in(userNoteIdList).count().castToNum(Integer.class); + NumberExpression middleCount = fmn.note.id.in(userNoteIdList).count().castToNum(Integer.class); + NumberExpression baseCount = fbn.note.id.in(userNoteIdList).count().castToNum(Integer.class); + + NumberExpression totalCount = topCount.add(middleCount).add(baseCount); + + NumberExpression priorityOrder = Expressions.cases() + .when(totalCount.eq(3)).then(1) + .when(totalCount.eq(2)).then(2) + .when(totalCount.eq(1)).then(3) + .otherwise(4); + + return queryFactory.selectFrom(f) + .distinct() + .leftJoin(f.fragranceTopNoteList, ftn).leftJoin(ftn.note, topNote) + .leftJoin(f.fragranceMiddleNoteList, fmn).leftJoin(fmn.note, middleNote) + .leftJoin(f.fragranceBaseNoteList, fbn).leftJoin(fbn.note, baseNote) + .where(predicate) + .groupBy(f.id) + .orderBy(priorityOrder.asc()) + .limit(6) + .fetch(); + } } diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java index a1b6ff4..ac603ef 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java @@ -1,5 +1,6 @@ package PerfumeOnMe.spring.service.fragrance; +import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; import PerfumeOnMe.spring.web.dto.fragrance.FragranceRequestDTO; import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; @@ -24,5 +25,7 @@ FragranceResponseDTO.FragranceSearchFinalResult searchFragrancesByFilter( // 향수 전체 리스트 조회 API FragranceResponseDTO.FragranceSearchFinalResult getFragranceListAll(FragranceRequestDTO.FragranceAllRequest request, Long userId); + + FragranceResponseDTO.FragranceMdChoiceResult getFragranceMdChoice(CustomUserDetails userDetails); } diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java index 4b0e43f..ac78928 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java @@ -12,6 +12,7 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; import PerfumeOnMe.spring.converter.FragranceConverter; import PerfumeOnMe.spring.domain.Fragrance; import PerfumeOnMe.spring.domain.User; @@ -174,4 +175,37 @@ private FragranceResponseDTO.FragranceSearchFinalResult getFragranceSearchFinalR return FragranceConverter.toSearchFinalResult(content, fragrancePage.hasNext()); } + // 메인페이지 향수 추천(Md's Choice) 목록 조회 API + @Override + public FragranceResponseDTO.FragranceMdChoiceResult getFragranceMdChoice(CustomUserDetails userDetails) { + + User user = userRepository.findUserByLoginId(userDetails.getUsername()) + .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + + FragranceGender fragranceGender = switch (user.getGender()) { + case MALE -> FragranceGender.MALE; + case FEMALE -> FragranceGender.FEMALE; + case NONE -> null; + }; + + List noteList = user.getUserNoteList().stream() + .map(userNote -> userNote.getNote().getId()) + .toList(); + + List userMdChoice = fragranceRepository.findByUserMdChoice(fragranceGender, noteList); + return getFragranceMdChoiceFinalResult(user.getId(), userMdChoice); + } + + // Md's Choice 목록에 즐겨찾기 정보 포함해서 최종 DTO 반환 + private FragranceResponseDTO.FragranceMdChoiceResult getFragranceMdChoiceFinalResult( + Long userId, List fragranceList) { + + List content = fragranceList.stream() + .map(fragrance -> { + boolean liked = (userId != null) && Like(userId, fragrance.getId()); + return FragranceConverter.toSearchResultDto(fragrance, liked); + }).toList(); + + return FragranceConverter.toMdChoiceResult(content); + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java index a9b96d0..94acce4 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java @@ -187,4 +187,18 @@ public ResponseEntity> getFragrancesMdChoice( + @AuthenticationPrincipal CustomUserDetails userDetails) { + FragranceResponseDTO.FragranceMdChoiceResult result = fragranceService.getFragranceMdChoice(userDetails); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java index 5655e44..9ed7309 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java @@ -112,6 +112,15 @@ public static class FragranceSearchFinalResult { private boolean hasNext; } + // 메인페이지 향수 추천(MD's Choice) 목록 + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class FragranceMdChoiceResult { + private List content; + } + } From 5c04dbd2997e9b24d8b50c2b2d572bcb7c143c50 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Mon, 21 Jul 2025 17:58:11 +0900 Subject: [PATCH 220/339] =?UTF-8?q?[Feature]=20user=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=EC=97=90=20=ED=94=84=EB=A1=9C=ED=95=84=20=EC=82=AC?= =?UTF-8?q?=EC=A7=84=20=EB=B3=80=EA=B2=BD=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80(#76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/PerfumeOnMe/spring/domain/User.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/domain/User.java b/src/main/java/PerfumeOnMe/spring/domain/User.java index 916fb83..8f78bf7 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/User.java +++ b/src/main/java/PerfumeOnMe/spring/domain/User.java @@ -118,4 +118,9 @@ public void addUserNote(UserNote userNote) { this.userNoteList.add(userNote); userNote.setUser(this); } + + // 프로필 사진 변경 메서드 + public void updateImageURL(String imageURL) { + this.imageURL = imageURL; + } } From 56668f49f0c5bffc181c5617f31b0068126b44c6 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Mon, 21 Jul 2025 17:58:44 +0900 Subject: [PATCH 221/339] =?UTF-8?q?[Feature]=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20=EC=82=AC=EC=A7=84=20=EB=B3=80=EA=B2=BD=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=20DTO=20=EC=83=9D=EC=84=B1(#76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java index da88bc9..a83c4bd 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java @@ -77,4 +77,10 @@ public static class UserNoteUpdate { @ValidUserNote private List noteCategoryId; } + + // 프로필 사진 변경 + @Getter + public static class ProfileImageUpdateRequest { + private String imageUrl; // s3Url (업로드 완료된 S3 이미지 주소) + } } From 6bc2077b11c8896e8b0453199a9870dfe57e96a9 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Mon, 21 Jul 2025 17:58:55 +0900 Subject: [PATCH 222/339] =?UTF-8?q?[Feature]=20=ED=94=84=EB=A1=9C=ED=95=84?= =?UTF-8?q?=20=EC=82=AC=EC=A7=84=20=EB=B3=80=EA=B2=BD=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84(#76)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/service/user/UserService.java | 2 ++ .../spring/service/user/UserServiceImpl.java | 9 +++++++++ .../spring/web/controller/UserController.java | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserService.java b/src/main/java/PerfumeOnMe/spring/service/user/UserService.java index 61f9777..e70e1d8 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserService.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserService.java @@ -27,4 +27,6 @@ public interface UserService { FragranceResponseDTO.FragranceSearchFinalResult getFavoriteFragrances( FragranceRequestDTO.FragranceAllRequest request, Long userId); + + void updateProfileImage(Long userId, String imageUrl); } diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java index 923ff98..0c94b15 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java @@ -204,6 +204,15 @@ public UserResponseDTO.MyPageProfileResponse getUserProfile(Long userId) { return UserConverter.toMyPageProfileResponse(user); } + // 마이페이지 프로필 사진 변경 + @Override + public void updateProfileImage(Long userId, String imageUrl) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + user.updateImageURL(imageUrl); + userRepository.save(user); + } + // 마이페이지 즐겨찾기 목록 조회 @Override public FragranceResponseDTO.FragranceSearchFinalResult getFavoriteFragrances( diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java index 19bebf8..1ff3371 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java @@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; @@ -166,4 +167,21 @@ public ResponseEntity> updateProfileImage( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody @Valid UserRequestDTO.ProfileImageUpdateRequest request) { + + userService.updateProfileImage(userDetails.getUserId(), request.getImageUrl()); + return ResponseEntity.ok(ApiResponse.onSuccess(null)); + } } From dcd3f2b334709705574ea6dced79d4d64b71563f Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 21 Jul 2025 18:25:04 +0900 Subject: [PATCH 223/339] =?UTF-8?q?[Fix]=20=EB=A9=94=EC=9D=B8=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=ED=96=A5=EC=88=98=20=EC=B6=94=EC=B2=9C(MD?= =?UTF-8?q?'s=20Choice)=20API=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?-=20JPQLQuery=20=EC=82=AC=EC=9A=A9=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fragrance/FragranceRepositoryImpl.java | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java index 998a26d..05ff507 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java @@ -14,6 +14,8 @@ import com.querydsl.core.types.dsl.Expressions; import com.querydsl.core.types.dsl.NumberExpression; import com.querydsl.core.types.dsl.StringPath; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.JPQLQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import PerfumeOnMe.spring.domain.Fragrance; @@ -159,28 +161,32 @@ public Page findByFilter(FragranceRequestDTO.FragranceFilterRequest r @Override public List findByUserMdChoice(FragranceGender gender, List userNoteIdList) { + QFragranceTopNote ftnSub = new QFragranceTopNote("ftnSub"); + QFragranceMiddleNote fmnSub = new QFragranceMiddleNote("fmnSub"); + QFragranceBaseNote fbnSub = new QFragranceBaseNote("fbnSub"); + BooleanBuilder predicate = new BooleanBuilder(); predicate.and(f.gender.eq(gender)); - NumberExpression topCount = ftn.note.id.in(userNoteIdList).count().castToNum(Integer.class); - NumberExpression middleCount = fmn.note.id.in(userNoteIdList).count().castToNum(Integer.class); - NumberExpression baseCount = fbn.note.id.in(userNoteIdList).count().castToNum(Integer.class); + JPQLQuery topCount = JPAExpressions.select(ftnSub.countDistinct()) + .from(ftnSub).where(ftnSub.fragrance.id.eq(f.id).and(ftnSub.note.id.in(userNoteIdList))); + JPQLQuery middleCount = JPAExpressions.select(fmnSub.countDistinct()) + .from(fmnSub).where(fmnSub.fragrance.id.eq(f.id).and(fmnSub.note.id.in(userNoteIdList))); + JPQLQuery baseCount = JPAExpressions.select(ftnSub.countDistinct()) + .from(fbnSub).where(fbnSub.fragrance.id.eq(f.id).and(fbnSub.note.id.in(userNoteIdList))); - NumberExpression totalCount = topCount.add(middleCount).add(baseCount); + NumberExpression totalCount = Expressions.numberTemplate(Long.class, "({0} + {1} + {2}", + topCount, middleCount, baseCount); NumberExpression priorityOrder = Expressions.cases() - .when(totalCount.eq(3)).then(1) - .when(totalCount.eq(2)).then(2) - .when(totalCount.eq(1)).then(3) + .when(totalCount.eq(3L)).then(1) + .when(totalCount.eq(2L)).then(2) + .when(totalCount.eq(1L)).then(3) .otherwise(4); return queryFactory.selectFrom(f) .distinct() - .leftJoin(f.fragranceTopNoteList, ftn).leftJoin(ftn.note, topNote) - .leftJoin(f.fragranceMiddleNoteList, fmn).leftJoin(fmn.note, middleNote) - .leftJoin(f.fragranceBaseNoteList, fbn).leftJoin(fbn.note, baseNote) .where(predicate) - .groupBy(f.id) .orderBy(priorityOrder.asc()) .limit(6) .fetch(); From ce96f4f56d0f726f94ed9b24148d1482eb85e8d1 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 21 Jul 2025 19:13:40 +0900 Subject: [PATCH 224/339] =?UTF-8?q?[Fix]=20=EB=A9=94=EC=9D=B8=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=ED=96=A5=EC=88=98=20=EC=B6=94=EC=B2=9C(MD?= =?UTF-8?q?'s=20Choice)=20API=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?-=20NativeQuery=20=EC=82=AC=EC=9A=A9=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fragrance/FragranceRepository.java | 31 +++++++++++++ .../fragrance/FragranceRepositoryCustom.java | 6 --- .../fragrance/FragranceRepositoryImpl.java | 43 ++----------------- .../fragrance/FragranceServiceImpl.java | 3 +- 4 files changed, 37 insertions(+), 46 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepository.java b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepository.java index f45ff84..9f69845 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepository.java @@ -1,8 +1,11 @@ package PerfumeOnMe.spring.repository.fragrance; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import PerfumeOnMe.spring.domain.Fragrance; @@ -10,4 +13,32 @@ public interface FragranceRepository extends JpaRepository, Fra Optional findByName(String name); Optional findById(Long id); + + @Query(value = """ + SELECT f.* + FROM fragrances f + LEFT JOIN ( + SELECT fn.fragrance_id, COUNT(DISTINCT fn.note_id) AS match_cnt + FROM ( + SELECT fragrance_id, note_id FROM fragrance_top_notes + UNION + SELECT fragrance_id, note_id FROM fragrance_middle_notes + UNION + SELECT fragrance_id, note_id FROM fragrance_base_notes + ) AS fn + WHERE fn.note_id IN (:noteIdList) + GROUP BY fn.fragrance_id + ) AS match_note ON match_note.fragrance_id = f.id + WHERE :gender IS NULL OR f.gender = :gender + ORDER BY + CASE + WHEN COALESCE(match_note.match_cnt, 0) = 3 THEN 1 + WHEN COALESCE(match_note.match_cnt, 0) = 2 THEN 2 + WHEN COALESCE(match_note.match_cnt, 0) = 1 THEN 3 + ELSE 4 + END ASC + LIMIT 6 + """, nativeQuery = true) + List findByUserMdChoice(@Param("gender") String gender, + @Param("noteIdList") List userNoteIdList); } diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java index 35342fb..1c443db 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java @@ -1,13 +1,11 @@ package PerfumeOnMe.spring.repository.fragrance; -import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import PerfumeOnMe.spring.domain.Fragrance; -import PerfumeOnMe.spring.domain.enums.FragranceGender; import PerfumeOnMe.spring.web.dto.fragrance.FragranceRequestDTO; public interface FragranceRepositoryCustom { @@ -19,8 +17,4 @@ public interface FragranceRepositoryCustom { // 향수 필터링 Page findByFilter(FragranceRequestDTO.FragranceFilterRequest request, Pageable pageable); - - // 메인페이지 향수 추천(Md's Choice) - List findByUserMdChoice(FragranceGender gender, List userNoteIdList); - } diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java index 05ff507..fc76795 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java @@ -11,11 +11,7 @@ import com.querydsl.core.BooleanBuilder; import com.querydsl.core.types.dsl.BooleanExpression; -import com.querydsl.core.types.dsl.Expressions; -import com.querydsl.core.types.dsl.NumberExpression; import com.querydsl.core.types.dsl.StringPath; -import com.querydsl.jpa.JPAExpressions; -import com.querydsl.jpa.JPQLQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import PerfumeOnMe.spring.domain.Fragrance; @@ -33,6 +29,8 @@ import PerfumeOnMe.spring.domain.mapping.QFragranceSeason; import PerfumeOnMe.spring.domain.mapping.QFragranceTopNote; import PerfumeOnMe.spring.web.dto.fragrance.FragranceRequestDTO; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; import lombok.RequiredArgsConstructor; @Repository @@ -40,7 +38,6 @@ public class FragranceRepositoryImpl implements FragranceRepositoryCustom { private final JPAQueryFactory queryFactory; - // Q 도메인 객체를 클래스 레벨에서 선언 private final QFragrance f = QFragrance.fragrance; private final QFragrancePrice fp = QFragrancePrice.fragrancePrice; @@ -55,6 +52,8 @@ public class FragranceRepositoryImpl implements FragranceRepositoryCustom { private final QNote topNote = new QNote("topNote"); private final QNote middleNote = new QNote("middleNote"); private final QNote baseNote = new QNote("baseNote"); + @PersistenceContext + private EntityManager em; // 향수 상세 @Override @@ -157,38 +156,4 @@ public Page findByFilter(FragranceRequestDTO.FragranceFilterRequest r return new PageImpl<>(result, pageable, total != null ? total : 0); } - - @Override - public List findByUserMdChoice(FragranceGender gender, List userNoteIdList) { - - QFragranceTopNote ftnSub = new QFragranceTopNote("ftnSub"); - QFragranceMiddleNote fmnSub = new QFragranceMiddleNote("fmnSub"); - QFragranceBaseNote fbnSub = new QFragranceBaseNote("fbnSub"); - - BooleanBuilder predicate = new BooleanBuilder(); - predicate.and(f.gender.eq(gender)); - - JPQLQuery topCount = JPAExpressions.select(ftnSub.countDistinct()) - .from(ftnSub).where(ftnSub.fragrance.id.eq(f.id).and(ftnSub.note.id.in(userNoteIdList))); - JPQLQuery middleCount = JPAExpressions.select(fmnSub.countDistinct()) - .from(fmnSub).where(fmnSub.fragrance.id.eq(f.id).and(fmnSub.note.id.in(userNoteIdList))); - JPQLQuery baseCount = JPAExpressions.select(ftnSub.countDistinct()) - .from(fbnSub).where(fbnSub.fragrance.id.eq(f.id).and(fbnSub.note.id.in(userNoteIdList))); - - NumberExpression totalCount = Expressions.numberTemplate(Long.class, "({0} + {1} + {2}", - topCount, middleCount, baseCount); - - NumberExpression priorityOrder = Expressions.cases() - .when(totalCount.eq(3L)).then(1) - .when(totalCount.eq(2L)).then(2) - .when(totalCount.eq(1L)).then(3) - .otherwise(4); - - return queryFactory.selectFrom(f) - .distinct() - .where(predicate) - .orderBy(priorityOrder.asc()) - .limit(6) - .fetch(); - } } diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java index ac78928..0f9f536 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java @@ -192,7 +192,8 @@ public FragranceResponseDTO.FragranceMdChoiceResult getFragranceMdChoice(CustomU .map(userNote -> userNote.getNote().getId()) .toList(); - List userMdChoice = fragranceRepository.findByUserMdChoice(fragranceGender, noteList); + List userMdChoice = fragranceRepository + .findByUserMdChoice((fragranceGender == null ? null : fragranceGender.name()), noteList); return getFragranceMdChoiceFinalResult(user.getId(), userMdChoice); } From b032f4824bdbfed71899aa896037c1309e2707a7 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 21 Jul 2025 19:45:04 +0900 Subject: [PATCH 225/339] =?UTF-8?q?[Fix]=20=EC=9D=91=EB=8B=B5=20DTO?= =?UTF-8?q?=EC=97=90=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EA=B3=BC=20=EB=8B=89=EB=84=A4=EC=9E=84=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/converter/FragranceConverter.java | 4 +++- .../spring/service/fragrance/FragranceServiceImpl.java | 6 +++--- .../spring/web/dto/fragrance/FragranceResponseDTO.java | 2 ++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java index 9fb7155..c9ade07 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java @@ -146,9 +146,11 @@ public static FragranceResponseDTO.FragranceSearchFinalResult toSearchFinalResul // 마이페이지 향수 추천(Md's Choice) 목록 반환 public static FragranceResponseDTO.FragranceMdChoiceResult toMdChoiceResult( - List content) { + List content, String name, String nickname) { return FragranceResponseDTO.FragranceMdChoiceResult.builder() .content(content) + .name(name) + .nickname(nickname) .build(); } diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java index 0f9f536..c6201c6 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java @@ -194,12 +194,12 @@ public FragranceResponseDTO.FragranceMdChoiceResult getFragranceMdChoice(CustomU List userMdChoice = fragranceRepository .findByUserMdChoice((fragranceGender == null ? null : fragranceGender.name()), noteList); - return getFragranceMdChoiceFinalResult(user.getId(), userMdChoice); + return getFragranceMdChoiceFinalResult(user.getId(), userMdChoice, user.getName(), user.getNickname()); } // Md's Choice 목록에 즐겨찾기 정보 포함해서 최종 DTO 반환 private FragranceResponseDTO.FragranceMdChoiceResult getFragranceMdChoiceFinalResult( - Long userId, List fragranceList) { + Long userId, List fragranceList, String name, String nickname) { List content = fragranceList.stream() .map(fragrance -> { @@ -207,6 +207,6 @@ private FragranceResponseDTO.FragranceMdChoiceResult getFragranceMdChoiceFinalRe return FragranceConverter.toSearchResultDto(fragrance, liked); }).toList(); - return FragranceConverter.toMdChoiceResult(content); + return FragranceConverter.toMdChoiceResult(content, name, nickname); } } diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java index 9ed7309..6b1f3de 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java @@ -119,6 +119,8 @@ public static class FragranceSearchFinalResult { @NoArgsConstructor public static class FragranceMdChoiceResult { private List content; + private String name; + private String nickname; } } From 39c4427a0e621bcecc7b50ea8b247373074ac802 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Mon, 21 Jul 2025 20:00:49 +0900 Subject: [PATCH 226/339] =?UTF-8?q?[Style]=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fragrance/FragranceRepository.java | 11 +++++++++++ .../service/fragrance/FragranceServiceImpl.java | 17 +++++++++-------- .../web/controller/FragranceController.java | 2 +- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepository.java b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepository.java index 9f69845..23732dc 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepository.java @@ -14,6 +14,17 @@ public interface FragranceRepository extends JpaRepository, Fra Optional findById(Long id); + /** + * 메인페이지 향수 추천(MD's Choice) - 사용자 성별과 선호 향으로 추천 + * 1. 향수 탑, 미들, 베이스 노트 아이디 합집합 + * 2. DISTINCT로 노트 아이디 중복 제거 + * 3. 사용자 선호 향과 일치하는 개수 카운트 + * 4. 개수 기반 우선순위 설정 후 정렬 + * 5. 6개 반환 + * @param gender = 사용자 성별; null로 들어온 경우 WHERE 절에서 무시 + * @param userNoteIdList = 사용자 선호 향 + * @return = 향수 6개 + */ @Query(value = """ SELECT f.* FROM fragrances f diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java index c6201c6..1992eee 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java @@ -18,6 +18,7 @@ import PerfumeOnMe.spring.domain.User; import PerfumeOnMe.spring.domain.enums.FragranceGender; import PerfumeOnMe.spring.domain.enums.FragranceType; +import PerfumeOnMe.spring.domain.enums.UserGender; import PerfumeOnMe.spring.domain.mapping.UserFragrance; import PerfumeOnMe.spring.repository.fragrance.FragranceRepository; import PerfumeOnMe.spring.repository.location.LocationRepository; @@ -175,25 +176,25 @@ private FragranceResponseDTO.FragranceSearchFinalResult getFragranceSearchFinalR return FragranceConverter.toSearchFinalResult(content, fragrancePage.hasNext()); } - // 메인페이지 향수 추천(Md's Choice) 목록 조회 API + // 메인페이지 향수 추천(MD's Choice) 목록 조회 API @Override public FragranceResponseDTO.FragranceMdChoiceResult getFragranceMdChoice(CustomUserDetails userDetails) { + // 사용자 조회 User user = userRepository.findUserByLoginId(userDetails.getUsername()) .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); - FragranceGender fragranceGender = switch (user.getGender()) { - case MALE -> FragranceGender.MALE; - case FEMALE -> FragranceGender.FEMALE; - case NONE -> null; - }; - + // 사용자 선호 향 조회 List noteList = user.getUserNoteList().stream() .map(userNote -> userNote.getNote().getId()) .toList(); + // 사용자 맞춤 향수 반환 + // 성별이 NONE인 경우, null 적용 (필터링 적용 X) List userMdChoice = fragranceRepository - .findByUserMdChoice((fragranceGender == null ? null : fragranceGender.name()), noteList); + .findByUserMdChoice((user.getGender() == UserGender.NONE ? null : user.getGender().name()), noteList); + + // 즐겨찾기 적용해서 응답 DTO로 반환 return getFragranceMdChoiceFinalResult(user.getId(), userMdChoice, user.getName(), user.getNickname()); } diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java index 94acce4..0b7caa0 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java @@ -191,7 +191,7 @@ public ResponseEntity Date: Mon, 21 Jul 2025 20:46:06 +0900 Subject: [PATCH 227/339] =?UTF-8?q?[Style]=20UserController=20URL=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/web/controller/UserController.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java index 1ff3371..f9d2faa 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java @@ -8,7 +8,6 @@ import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; @@ -119,7 +118,7 @@ public ResponseEntity> onboarding(@Valid @RequestBody UserRe return ResponseEntity.ok().body(ApiResponse.onSuccess(null)); } - @PatchMapping("/notes") + @PatchMapping("/me/notes") @Operation( summary = "선호 향 수정 API", description = "사용자의 선호하는 향 리스트를 입력받아 수정하는 API입니다.", @@ -168,7 +167,7 @@ public ResponseEntity Date: Mon, 21 Jul 2025 20:46:06 +0900 Subject: [PATCH 228/339] =?UTF-8?q?[Fix]=20UserController=20URL=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=B0=8F=20PUT=EC=97=90=EC=84=9C=20PATCH?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/web/controller/UserController.java | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java index 1ff3371..f9d2faa 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java @@ -8,7 +8,6 @@ import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; @@ -119,7 +118,7 @@ public ResponseEntity> onboarding(@Valid @RequestBody UserRe return ResponseEntity.ok().body(ApiResponse.onSuccess(null)); } - @PatchMapping("/notes") + @PatchMapping("/me/notes") @Operation( summary = "선호 향 수정 API", description = "사용자의 선호하는 향 리스트를 입력받아 수정하는 API입니다.", @@ -168,7 +167,7 @@ public ResponseEntity Date: Mon, 21 Jul 2025 21:12:50 +0900 Subject: [PATCH 229/339] =?UTF-8?q?[Feature]=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20ENUM=ED=83=80=EC=9E=85=20String->?= =?UTF-8?q?enum=20=EB=B3=80=ED=99=98=20=ED=95=A8=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/PerfumeOnMe/spring/domain/enums/Ambience.java | 9 +++++++++ .../java/PerfumeOnMe/spring/domain/enums/Gender.java | 9 +++++++++ .../PerfumeOnMe/spring/domain/enums/Personality.java | 9 +++++++++ .../java/PerfumeOnMe/spring/domain/enums/Season.java | 9 +++++++++ src/main/java/PerfumeOnMe/spring/domain/enums/Style.java | 9 +++++++++ 5 files changed, 45 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Ambience.java b/src/main/java/PerfumeOnMe/spring/domain/enums/Ambience.java index 5664d8f..7fbd2f8 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/Ambience.java +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/Ambience.java @@ -20,4 +20,13 @@ public enum Ambience { Ambience(String displayName) { this.displayName = displayName; } + + public static Ambience fromDisplayName(String displayName) { + for (Ambience value : Ambience.values()) { + if (value.getDisplayName().equals(displayName)) { + return value; + } + } + throw new IllegalArgumentException("Invalid displayName for Ambience: " + displayName); + } } diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Gender.java b/src/main/java/PerfumeOnMe/spring/domain/enums/Gender.java index 73c0341..0c66b54 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/Gender.java +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/Gender.java @@ -13,4 +13,13 @@ public enum Gender { Gender(String displayName) { this.displayName = displayName; } + + public static Gender fromDisplayName(String displayName) { + for (Gender g : Gender.values()) { + if (g.getDisplayName().equals(displayName)) { + return g; + } + } + throw new IllegalArgumentException("Invalid displayName for Gender: " + displayName); + } } diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Personality.java b/src/main/java/PerfumeOnMe/spring/domain/enums/Personality.java index adaac29..d0b639e 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/Personality.java +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/Personality.java @@ -20,4 +20,13 @@ public enum Personality { Personality(String displayName) { this.displayName = displayName; } + + public static Personality fromDisplayName(String displayName) { + for (Personality p : Personality.values()) { + if (p.getDisplayName().equals(displayName)) { + return p; + } + } + throw new IllegalArgumentException("Invalid displayName for Personality: " + displayName); + } } diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Season.java b/src/main/java/PerfumeOnMe/spring/domain/enums/Season.java index 200da6e..313bd35 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/Season.java +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/Season.java @@ -14,4 +14,13 @@ public enum Season { Season(String displayName) { this.displayName = displayName; } + + public static Season fromDisplayName(String displayName) { + for (Season season : Season.values()) { + if (season.getDisplayName().equals(displayName)) { + return season; + } + } + throw new IllegalArgumentException("Invalid displayName for Season: " + displayName); + } } diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Style.java b/src/main/java/PerfumeOnMe/spring/domain/enums/Style.java index b2fc0c5..001babe 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/Style.java +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/Style.java @@ -20,4 +20,13 @@ public enum Style { Style(String displayName) { this.displayName = displayName; } + + public static Style fromDisplayName(String displayName) { + for (Style style : Style.values()) { + if (style.getDisplayName().equals(displayName)) { + return style; + } + } + throw new IllegalArgumentException("Invalid displayName for Style: " + displayName); + } } From 9cef4d7a7b0aac6358f136faaa4b4564d6928a58 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Mon, 21 Jul 2025 21:14:08 +0900 Subject: [PATCH 230/339] =?UTF-8?q?[Feature]=20=EC=97=90=EB=9F=AC=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=B6=94=EA=B0=80,=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EA=B0=92=20validator=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apiPayload/code/status/ErrorStatus.java | 5 +++ .../annotation/ValidEnumKeyword.java | 26 ++++++++++++++ .../validator/ValidEnumKeywordValidator.java | 35 +++++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/validation/annotation/ValidEnumKeyword.java create mode 100644 src/main/java/PerfumeOnMe/spring/validation/validator/ValidEnumKeywordValidator.java diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index f2f99e1..7ce62ab 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -72,8 +72,13 @@ public enum ErrorStatus implements BaseErrorCode { ALREADY_KEYWORD_NAME(HttpStatus.BAD_REQUEST, "IMAGEKEYWORD4003", "동일한 이름으로 저장된 결과가 존재합니다."), INVALID_IMAGEKEYWORD_ID(HttpStatus.BAD_REQUEST, "IMAGEKEYWORD4004", "해당 ID의 이미지 결과가 존재하지 않습니다."), + // FastAPI 연동 에러 + FASTAPI_COMMUNICATION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "FASTAPI5001", "FastAPI 서버 통신 중 오류가 발생했습니다."), + //JSON 파싱 에러 JSON_PARSE_ERROR(HttpStatus.BAD_REQUEST, "JSON4001", "JSON 파싱에 실패했습니다."), + + // // 예시,,, ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidEnumKeyword.java b/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidEnumKeyword.java new file mode 100644 index 0000000..1202bbf --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidEnumKeyword.java @@ -0,0 +1,26 @@ +package PerfumeOnMe.spring.validation.annotation; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import PerfumeOnMe.spring.validation.validator.ValidEnumKeywordValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Documented +@Constraint(validatedBy = ValidEnumKeywordValidator.class) +@Target({ElementType.FIELD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidEnumKeyword { + String message() default "올바르지 않은 키워드입니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; + + // enum 클래스 명시 + Class> enumClass(); +} diff --git a/src/main/java/PerfumeOnMe/spring/validation/validator/ValidEnumKeywordValidator.java b/src/main/java/PerfumeOnMe/spring/validation/validator/ValidEnumKeywordValidator.java new file mode 100644 index 0000000..e9f18f5 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/validation/validator/ValidEnumKeywordValidator.java @@ -0,0 +1,35 @@ +package PerfumeOnMe.spring.validation.validator; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import PerfumeOnMe.spring.validation.annotation.ValidEnumKeyword; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class ValidEnumKeywordValidator implements ConstraintValidator { + + private Class> enumClass; + + @Override + public void initialize(ValidEnumKeyword constraintAnnotation) { + this.enumClass = constraintAnnotation.enumClass(); + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null || value.trim().isEmpty()) + return false; + + return Arrays.stream(enumClass.getEnumConstants()) + .anyMatch(e -> { + try { + Method getDisplayName = enumClass.getMethod("getDisplayName"); + String displayName = (String)getDisplayName.invoke(e); + return displayName.equals(value); + } catch (Exception ex) { + return false; + } + }); + } +} From e8d8d9802330d253c89897dd1880a9b8fdf41cbe Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Mon, 21 Jul 2025 21:15:40 +0900 Subject: [PATCH 231/339] =?UTF-8?q?[Feature]=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EA=B2=B0=EA=B3=BC=20DTO=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../imagekeyword/ImageKeywordRequestDTO.java | 33 +++++++++++++++++- .../imagekeyword/ImageKeywordResponseDTO.java | 34 +++++++++++++++++-- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordRequestDTO.java index d0df577..bd378cc 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordRequestDTO.java @@ -1,4 +1,35 @@ package PerfumeOnMe.spring.web.dto.imagekeyword; +import PerfumeOnMe.spring.domain.enums.Ambience; +import PerfumeOnMe.spring.domain.enums.Gender; +import PerfumeOnMe.spring.domain.enums.Personality; +import PerfumeOnMe.spring.domain.enums.Season; +import PerfumeOnMe.spring.domain.enums.Style; +import PerfumeOnMe.spring.validation.annotation.ValidEnumKeyword; +import lombok.Getter; +import lombok.Setter; + +/** + * 이미지 키워드 관련 요청 DTO 모음 클래스 + */ public class ImageKeywordRequestDTO { -} + @Getter + @Setter + public static class ImageKeywordPreviewRequestDTO { + + @ValidEnumKeyword(enumClass = Ambience.class) + private String ambience; + + @ValidEnumKeyword(enumClass = Style.class) + private String style; + + @ValidEnumKeyword(enumClass = Gender.class) + private String gender; + + @ValidEnumKeyword(enumClass = Season.class) + private String season; + + @ValidEnumKeyword(enumClass = Personality.class) + private String personality; + } +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordResponseDTO.java index b5e8da1..08f8685 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordResponseDTO.java @@ -8,9 +8,10 @@ import lombok.Getter; import lombok.NoArgsConstructor; +// 이미지 키워드 응답 DTO public class ImageKeywordResponseDTO { - // 이미지키워드 목록 조회 응답 DTO + // 이미지키워드 목록 조회 응답 DTO - 마이페이지 목록 조회용 @Getter @Builder @AllArgsConstructor @@ -21,6 +22,7 @@ public static class ImageKeywordListResponseDTO { private LocalDateTime createdAt; } + // 상세 조회용 @Getter @Builder @AllArgsConstructor @@ -47,4 +49,32 @@ public static class FragranceRecommendation { private List relatedKeywords; } } -} + + // Preview 응답용 (preview와 detail은 구조 동일 -> 저장 전 미리보기) + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class ImageKeywordPreviewResponseDTO { + private List keywords; + private String descriptions; + private String scenario; + private String characterImageUrl; + private List recommendations; + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class FragranceRecommendation { + private String brand; + private String name; + private String topNote; + private String middleNote; + private String baseNote; + private String description; + private List relatedKeywords; + } + } + +} \ No newline at end of file From 2e07f83165470843612a9c868f761cc69282a6e5 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Mon, 21 Jul 2025 21:17:13 +0900 Subject: [PATCH 232/339] =?UTF-8?q?[Feature]=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EA=B2=B0=EA=B3=BC=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20service=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ImageKeywordDescriptionService.java | 49 +++++++++++ .../ImageKeywordPreviewService.java | 86 +++++++++++++++++++ .../imagekeyword/ImageKeywordService.java | 2 +- .../imagekeyword/ImageKeywordServiceImpl.java | 2 +- 4 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordDescriptionService.java create mode 100644 src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordPreviewService.java diff --git a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordDescriptionService.java b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordDescriptionService.java new file mode 100644 index 0000000..541873e --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordDescriptionService.java @@ -0,0 +1,49 @@ +package PerfumeOnMe.spring.service.imagekeyword; + +import java.util.List; +import java.util.stream.Stream; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import PerfumeOnMe.spring.domain.ImageKeywordDescription; +import PerfumeOnMe.spring.domain.enums.Ambience; +import PerfumeOnMe.spring.domain.enums.Gender; +import PerfumeOnMe.spring.domain.enums.KeywordCategory; +import PerfumeOnMe.spring.domain.enums.Personality; +import PerfumeOnMe.spring.domain.enums.Season; +import PerfumeOnMe.spring.domain.enums.Style; +import PerfumeOnMe.spring.repository.imagekeyworddescription.ImageKeywordDescriptionRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ImageKeywordDescriptionService { + + private final ImageKeywordDescriptionRepository descriptionRepository; + + /** + * 키워드 + 카테고리 매핑 기반 설명 조합 + * 순서대로 조회된 description들을 join하여 하나의 문장으로 반환 + */ + public String getDescriptions(String ambience, String style, String gender, String season, String personality) { + List descriptions = Stream.of( + new EnumWithCategory(Ambience.fromDisplayName(ambience).name(), KeywordCategory.AMBIENCE), + new EnumWithCategory(Style.fromDisplayName(style).name(), KeywordCategory.STYLE), + new EnumWithCategory(Season.fromDisplayName(season).name(), KeywordCategory.SEASON), + new EnumWithCategory(Personality.fromDisplayName(personality).name(), KeywordCategory.PERSONALITY), + new EnumWithCategory(Gender.fromDisplayName(gender).name(), KeywordCategory.GENDER) + ) + .map(pair -> descriptionRepository + .findByKeywordAndCategory(pair.keyword(), pair.category()) + .map(ImageKeywordDescription::getDescription) + .orElse("")) + .toList(); + + return String.join(" ", descriptions); + } + + private record EnumWithCategory(String keyword, KeywordCategory category) { + } +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordPreviewService.java b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordPreviewService.java new file mode 100644 index 0000000..cf1bac4 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordPreviewService.java @@ -0,0 +1,86 @@ +package PerfumeOnMe.spring.service.imagekeyword; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import PerfumeOnMe.spring.service.external.FastApiClient; +import PerfumeOnMe.spring.service.redis.ImageKeywordRedisService; +import PerfumeOnMe.spring.web.dto.external.FastApiRecommendRequest; +import PerfumeOnMe.spring.web.dto.external.FastApiRecommendResponse; +import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordRequestDTO.ImageKeywordPreviewRequestDTO; +import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordResponseDTO.ImageKeywordPreviewResponseDTO; +import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordResponseDTO.ImageKeywordPreviewResponseDTO.FragranceRecommendation; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class ImageKeywordPreviewService { + + private static final String TEMP_CHARACTER_IMAGE_URL = "https://s3.amazonaws.com/your-bucket/image-keyword/temp-character.png"; + private final ImageKeywordDescriptionService descriptionService; + private final FastApiClient fastApiClient; + private final ImageKeywordRedisService redisService; + + /** + * 키워드 기반 감성 시나리오 + 향수 추천 결과 생성 후 Redis에 저장 + */ + public ImageKeywordPreviewResponseDTO generatePreview(Long userId, ImageKeywordPreviewRequestDTO request) { + + // ✅ 1. 키워드 리스트 + List keywords = List.of( + request.getAmbience(), request.getStyle(), request.getSeason(), + request.getPersonality(), request.getGender() + ); + + // ✅ 2. 설명 조합 + String descriptions = descriptionService.getDescriptions( + request.getAmbience(), + request.getStyle(), + request.getGender(), + request.getSeason(), + request.getPersonality() + ); + + // ✅ 3. FastAPI 추천 요청 + FastApiRecommendRequest fastApiRequest = new FastApiRecommendRequest( + request.getAmbience(), + request.getStyle(), + request.getGender(), + request.getSeason(), + request.getPersonality() + ); + + FastApiRecommendResponse fastApiResponse = fastApiClient.getFullRecommendation(fastApiRequest); + + // ✅ 4. 추천 향수 매핑 + List recommendations = fastApiResponse.getRecommendations().stream() + .map(f -> FragranceRecommendation.builder() + .brand(f.getBrand()) + .name(f.getName()) + .topNote(f.getTopNote()) + .middleNote(f.getMiddleNote()) + .baseNote(f.getBaseNote()) + .description(f.getDescription()) + .relatedKeywords(f.getRelatedKeywords()) + .build() + ).collect(Collectors.toList()); + + // ✅ 5. 최종 Preview 응답 구성 + ImageKeywordPreviewResponseDTO previewDTO = ImageKeywordPreviewResponseDTO.builder() + .keywords(keywords) + .descriptions(descriptions) + .scenario(fastApiResponse.getScenario()) + .characterImageUrl(TEMP_CHARACTER_IMAGE_URL) // 추후 S3 동적 처리 예정 + .recommendations(recommendations) + .build(); + + // ✅ 6. Redis 저장 (TTL 15분) + redisService.savePreview(userId, previewDTO); + + return previewDTO; + } +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordService.java b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordService.java index cfae6f5..e57e489 100644 --- a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordService.java +++ b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordService.java @@ -8,4 +8,4 @@ public interface ImageKeywordService { List getImageKeywordList(Long userId); ImageKeywordResponseDTO.ImageKeywordDetailResponseDTO getImageKeywordDetail(Long userId, Long imageKeywordId); -} +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java index edc49ab..41da0ff 100644 --- a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java @@ -46,4 +46,4 @@ public ImageKeywordResponseDTO.ImageKeywordDetailResponseDTO getImageKeywordDeta .orElseThrow(() -> new GeneralException(ErrorStatus.INVALID_IMAGEKEYWORD_ID)); return ImageKeywordConverter.toImageKeywordDetailResponse(keyword, imageKeywordDescriptionRepository); } -} +} \ No newline at end of file From bb563c09b00f842be7b5b3b93f96172c8997a729 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Mon, 21 Jul 2025 21:18:17 +0900 Subject: [PATCH 233/339] =?UTF-8?q?[Feature]=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=ED=96=A5=EC=88=98=EC=B6=94?= =?UTF-8?q?=EC=B2=9C/=EA=B0=90=EC=84=B1=EC=8B=9C=EB=82=98=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=20=EA=B2=B0=EA=B3=BC=EB=A5=BC=20=EC=9C=84=ED=95=9C=20?= =?UTF-8?q?FastAPI=ED=98=B8=EC=B6=9C=20=EA=B5=AC=ED=98=84=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/external/FastApiClient.java | 43 +++++++++++++++++++ .../dto/external/FastApiRecommendRequest.java | 18 ++++++++ .../external/FastApiRecommendResponse.java | 25 +++++++++++ 3 files changed, 86 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/service/external/FastApiClient.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiRecommendRequest.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiRecommendResponse.java diff --git a/src/main/java/PerfumeOnMe/spring/service/external/FastApiClient.java b/src/main/java/PerfumeOnMe/spring/service/external/FastApiClient.java new file mode 100644 index 0000000..a54e3d4 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/external/FastApiClient.java @@ -0,0 +1,43 @@ +package PerfumeOnMe.spring.service.external; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.web.dto.external.FastApiRecommendRequest; +import PerfumeOnMe.spring.web.dto.external.FastApiRecommendResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class FastApiClient { + + private final RestTemplate restTemplate; + + @Value("${external.fastapi.recommend-url}") + private String fastApiRecommendUrl; + + public FastApiRecommendResponse getFullRecommendation(FastApiRecommendRequest request) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(request, headers); + + ResponseEntity response = + restTemplate.exchange(fastApiRecommendUrl, HttpMethod.POST, entity, FastApiRecommendResponse.class); + + return response.getBody(); + + } catch (Exception e) { + throw new GeneralException(ErrorStatus.FASTAPI_COMMUNICATION_ERROR); + } + } +} diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiRecommendRequest.java b/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiRecommendRequest.java new file mode 100644 index 0000000..0330d81 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiRecommendRequest.java @@ -0,0 +1,18 @@ +package PerfumeOnMe.spring.web.dto.external; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class FastApiRecommendRequest { + private String ambience; + private String style; + private String gender; + private String season; + private String personality; +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiRecommendResponse.java b/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiRecommendResponse.java new file mode 100644 index 0000000..6a6a45f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiRecommendResponse.java @@ -0,0 +1,25 @@ +package PerfumeOnMe.spring.web.dto.external; + +import java.util.List; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class FastApiRecommendResponse { + private String scenario; + private List recommendations; + + @Getter + @NoArgsConstructor + public static class FragranceRecommendation { + private String brand; + private String name; + private String topNote; + private String middleNote; + private String baseNote; + private String description; + private List relatedKeywords; + } +} From af14bb1824fed9e7b2fbcf0d1c351e4c99360f08 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Mon, 21 Jul 2025 21:18:51 +0900 Subject: [PATCH 234/339] =?UTF-8?q?[Feature]=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EB=AF=B8=EB=A6=AC=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EA=B2=B0=EA=B3=BC=20redis=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../redis/ImageKeywordRedisService.java | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/service/redis/ImageKeywordRedisService.java diff --git a/src/main/java/PerfumeOnMe/spring/service/redis/ImageKeywordRedisService.java b/src/main/java/PerfumeOnMe/spring/service/redis/ImageKeywordRedisService.java new file mode 100644 index 0000000..42c97d2 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/redis/ImageKeywordRedisService.java @@ -0,0 +1,55 @@ +package PerfumeOnMe.spring.service.redis; + +import java.time.Duration; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordResponseDTO; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ImageKeywordRedisService { + + private static final Duration TTL = Duration.ofMinutes(15); + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + public void savePreview(Long userId, ImageKeywordResponseDTO.ImageKeywordPreviewResponseDTO dto) { + try { + String key = buildKey(userId); + String json = objectMapper.writeValueAsString(dto); + redisTemplate.opsForValue().set(key, json, TTL); + } catch (Exception e) { + throw new GeneralException(ErrorStatus.JSON_PARSE_ERROR); + } + } + + public ImageKeywordResponseDTO.ImageKeywordPreviewResponseDTO getPreview(Long userId) { + try { + String key = buildKey(userId); + String json = redisTemplate.opsForValue().get(key); + if (json == null) { + throw new GeneralException(ErrorStatus.EXPIRED_IMAGEKEYWORD_RESULT); + } + return objectMapper.readValue(json, ImageKeywordResponseDTO.ImageKeywordPreviewResponseDTO.class); + } catch (GeneralException e) { + throw e; + } catch (Exception e) { + throw new GeneralException(ErrorStatus.JSON_PARSE_ERROR); + } + } + + public void deletePreview(Long userId) { + redisTemplate.delete(buildKey(userId)); + } + + private String buildKey(Long userId) { + return "image-keyword:preview:" + userId; + } +} \ No newline at end of file From 4f75c1fac56aeec23474e0671084db0ad82cadbb Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Mon, 21 Jul 2025 21:19:13 +0900 Subject: [PATCH 235/339] =?UTF-8?q?[Feature]=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EB=AF=B8=EB=A6=AC=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=20=EA=B2=B0=EA=B3=BC=20Controller=EA=B5=AC=ED=98=84(#?= =?UTF-8?q?27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + .../controller/ImageKeywordController.java | 28 ++++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 6414f32..3a53862 100644 --- a/build.gradle +++ b/build.gradle @@ -69,6 +69,7 @@ dependencies { // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.apache.commons:commons-pool2' // 커넥션 풀링 + implementation 'com.fasterxml.jackson.core:jackson-databind' // OAuth implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' // Query Parameter Log diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java b/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java index c916c29..4ca187f 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java @@ -4,14 +4,19 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import PerfumeOnMe.spring.apiPayload.ApiResponse; import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; +import PerfumeOnMe.spring.service.imagekeyword.ImageKeywordPreviewService; import PerfumeOnMe.spring.service.imagekeyword.ImageKeywordService; +import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordRequestDTO; import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordResponseDTO; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -26,6 +31,7 @@ @Tag(name = "Image-Keyword", description = "이미지키워드 API") public class ImageKeywordController { private final ImageKeywordService imageKeywordService; + private final ImageKeywordPreviewService previewService; // 이미지키워드 목록 조회 API @GetMapping("/result/list") @@ -76,4 +82,24 @@ public ResponseEntity> getImageKeywordPreview( + @Parameter(hidden = true) + @AuthenticationPrincipal CustomUserDetails userDetails, + @Validated @RequestBody ImageKeywordRequestDTO.ImageKeywordPreviewRequestDTO request + ) { + Long userId = userDetails.getUserId(); + ImageKeywordResponseDTO.ImageKeywordPreviewResponseDTO result = previewService.generatePreview(userId, request); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } +} \ No newline at end of file From ab0055aac63a25245b78a1922e4c36d9c234b8de Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Mon, 21 Jul 2025 23:16:24 +0900 Subject: [PATCH 236/339] =?UTF-8?q?[Feature]=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EA=B4=80=EB=A0=A8=20Util?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=83=9D=EC=84=B1(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/util/EnumDisplayNameMapper.java | 32 +++++++++++++++++++ .../PerfumeOnMe/spring/util/JsonUtils.java | 17 ++++++++++ 2 files changed, 49 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/util/EnumDisplayNameMapper.java create mode 100644 src/main/java/PerfumeOnMe/spring/util/JsonUtils.java diff --git a/src/main/java/PerfumeOnMe/spring/util/EnumDisplayNameMapper.java b/src/main/java/PerfumeOnMe/spring/util/EnumDisplayNameMapper.java new file mode 100644 index 0000000..febb6e3 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/util/EnumDisplayNameMapper.java @@ -0,0 +1,32 @@ +package PerfumeOnMe.spring.util; + +import java.util.List; + +import PerfumeOnMe.spring.domain.enums.Ambience; +import PerfumeOnMe.spring.domain.enums.Gender; +import PerfumeOnMe.spring.domain.enums.Personality; +import PerfumeOnMe.spring.domain.enums.Season; +import PerfumeOnMe.spring.domain.enums.Style; + +public class EnumDisplayNameMapper { + + public static Ambience toAmbience(List keywords) { + return Ambience.fromDisplayName(keywords.get(0)); + } + + public static Style toStyle(List keywords) { + return Style.fromDisplayName(keywords.get(1)); + } + + public static Season toSeason(List keywords) { + return Season.fromDisplayName(keywords.get(2)); + } + + public static Personality toPersonality(List keywords) { + return Personality.fromDisplayName(keywords.get(3)); + } + + public static Gender toGender(List keywords) { + return Gender.fromDisplayName(keywords.get(4)); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/util/JsonUtils.java b/src/main/java/PerfumeOnMe/spring/util/JsonUtils.java new file mode 100644 index 0000000..f459b3e --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/util/JsonUtils.java @@ -0,0 +1,17 @@ +package PerfumeOnMe.spring.util; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +// JSON 직렬화 메서드 +public class JsonUtils { + private static final ObjectMapper mapper = new ObjectMapper(); + + public static String toJson(Object obj) { + try { + return mapper.writeValueAsString(obj); + } catch (JsonProcessingException e) { + throw new RuntimeException("JSON 직렬화 실패", e); + } + } +} From b305ab70a69407c2c2273d4f23efd6f38df77e78 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Mon, 21 Jul 2025 23:16:56 +0900 Subject: [PATCH 237/339] =?UTF-8?q?[Feature]=20=EB=B6=84=EC=9C=84=EA=B8=B0?= =?UTF-8?q?=20=EA=B0=92=20=EB=9F=AD=EC=85=94=EB=A6=AC=ED=95=9C=20->=20?= =?UTF-8?q?=EB=9F=AC=EB=B8=94=EB=A6=AC=ED=95=9C=20=EB=B3=80=EA=B2=BD(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/PerfumeOnMe/spring/domain/enums/Ambience.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Ambience.java b/src/main/java/PerfumeOnMe/spring/domain/enums/Ambience.java index 7fbd2f8..e108677 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/Ambience.java +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/Ambience.java @@ -8,7 +8,7 @@ public enum Ambience { CUTE("귀여운"), CALM("차분한"), MATURE("성숙한"), - LUXURIOUS("럭셔리한"), + LOVELY("러블리한"), ELEGANT("시크한"), FRESH("신비로운"), BRIGHT("밝은"), From a5df556fc8f4b82c822ed2e2e69a566da5373f25 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Mon, 21 Jul 2025 23:17:43 +0900 Subject: [PATCH 238/339] =?UTF-8?q?[Feature]=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=A0=80=EC=9E=A5=20DTO=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/dto/imagekeyword/ImageKeywordRequestDTO.java | 6 ++++++ .../web/dto/imagekeyword/ImageKeywordResponseDTO.java | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordRequestDTO.java index bd378cc..d1ea7e7 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordRequestDTO.java @@ -32,4 +32,10 @@ public static class ImageKeywordPreviewRequestDTO { @ValidEnumKeyword(enumClass = Personality.class) private String personality; } + + @Getter + @Setter + public static class ImageKeywordSaveRequestDTO { + private String savedName; + } } \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordResponseDTO.java index 08f8685..a0ff39c 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordResponseDTO.java @@ -77,4 +77,14 @@ public static class FragranceRecommendation { } } + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class ImageKeywordSaveResponseDTO { + private Long imageKeywordId; + private String savedName; + private LocalDateTime createdAt; + } + } \ No newline at end of file From 4d91d32db1d41d86091897f7edb655ec426c26e8 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Mon, 21 Jul 2025 23:18:32 +0900 Subject: [PATCH 239/339] =?UTF-8?q?[Feature]=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=A0=80=EC=9E=A5=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4,=EB=A6=AC=ED=8F=AC=EC=A7=80=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EC=83=9D=EC=84=B1(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../imagekeyword/ImageKeywordRepository.java | 2 + .../imagekeyword/ImageKeywordService.java | 2 + .../imagekeyword/ImageKeywordServiceImpl.java | 44 +++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/repository/imagekeyword/ImageKeywordRepository.java b/src/main/java/PerfumeOnMe/spring/repository/imagekeyword/ImageKeywordRepository.java index b449ed2..53a6cc0 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/imagekeyword/ImageKeywordRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/imagekeyword/ImageKeywordRepository.java @@ -13,4 +13,6 @@ public interface ImageKeywordRepository extends JpaRepository findByIdAndUser(Long id, User user); + // 동일한 사용자와 저장 이름이 존재하는지 확인 + boolean existsByUserAndSavedName(User user, String savedName); } diff --git a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordService.java b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordService.java index e57e489..38f2aa4 100644 --- a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordService.java +++ b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordService.java @@ -8,4 +8,6 @@ public interface ImageKeywordService { List getImageKeywordList(Long userId); ImageKeywordResponseDTO.ImageKeywordDetailResponseDTO getImageKeywordDetail(Long userId, Long imageKeywordId); + + ImageKeywordResponseDTO.ImageKeywordSaveResponseDTO saveImageKeyword(Long userId, String savedName); } \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java index 41da0ff..046dded 100644 --- a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java @@ -13,6 +13,9 @@ import PerfumeOnMe.spring.repository.imagekeyword.ImageKeywordRepository; import PerfumeOnMe.spring.repository.imagekeyworddescription.ImageKeywordDescriptionRepository; import PerfumeOnMe.spring.repository.user.UserRepository; +import PerfumeOnMe.spring.service.redis.ImageKeywordRedisService; +import PerfumeOnMe.spring.util.EnumDisplayNameMapper; +import PerfumeOnMe.spring.util.JsonUtils; import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordResponseDTO; import lombok.RequiredArgsConstructor; @@ -23,6 +26,7 @@ public class ImageKeywordServiceImpl implements ImageKeywordService { private final ImageKeywordRepository imageKeywordRepository; private final UserRepository userRepository; private final ImageKeywordDescriptionRepository imageKeywordDescriptionRepository; + private final ImageKeywordRedisService redisService; @Override @Transactional(readOnly = true) @@ -46,4 +50,44 @@ public ImageKeywordResponseDTO.ImageKeywordDetailResponseDTO getImageKeywordDeta .orElseThrow(() -> new GeneralException(ErrorStatus.INVALID_IMAGEKEYWORD_ID)); return ImageKeywordConverter.toImageKeywordDetailResponse(keyword, imageKeywordDescriptionRepository); } + + @Override + @Transactional + public ImageKeywordResponseDTO.ImageKeywordSaveResponseDTO saveImageKeyword(Long userId, String savedName) { + // 사용자 검증 + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + // 중복 이름 체크 + if (imageKeywordRepository.existsByUserAndSavedName(user, savedName)) { + throw new GeneralException(ErrorStatus.ALREADY_KEYWORD_NAME); + } + // ✅ Redis에서 미리보기 결과 조회 + ImageKeywordResponseDTO.ImageKeywordPreviewResponseDTO cachedPreview = redisService.getPreview(userId); + if (cachedPreview == null) { + throw new GeneralException(ErrorStatus.EXPIRED_IMAGEKEYWORD_RESULT); // IK4002 + } + + // ✅ Entity 생성 및 저장 + ImageKeyword entity = ImageKeyword.builder() + .user(user) + .savedName(savedName) + .scenario(cachedPreview.getScenario()) + .imageUrl(cachedPreview.getCharacterImageUrl()) + .ambience(EnumDisplayNameMapper.toAmbience(cachedPreview.getKeywords())) + .style(EnumDisplayNameMapper.toStyle(cachedPreview.getKeywords())) + .gender(EnumDisplayNameMapper.toGender(cachedPreview.getKeywords())) + .season(EnumDisplayNameMapper.toSeason(cachedPreview.getKeywords())) + .personality(EnumDisplayNameMapper.toPersonality(cachedPreview.getKeywords())) + .keywordDescription(cachedPreview.getDescriptions()) + .recommendedFragranceJson(JsonUtils.toJson(cachedPreview.getRecommendations())) + .build(); + + ImageKeyword saved = imageKeywordRepository.save(entity); + + // ✅ Redis 키 삭제 + redisService.deletePreview(userId); + + return ImageKeywordConverter.toSaveResponseDTO(saved); + + } } \ No newline at end of file From 3e6310ecfd367eee74d13558018fbf18d95098a8 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Mon, 21 Jul 2025 23:18:55 +0900 Subject: [PATCH 240/339] =?UTF-8?q?[Feature]=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=A0=80=EC=9E=A5=20converter?= =?UTF-8?q?,=20controller=20=EC=B6=94=EA=B0=80(#27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../converter/ImageKeywordConverter.java | 8 ++++++ .../controller/ImageKeywordController.java | 28 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/converter/ImageKeywordConverter.java b/src/main/java/PerfumeOnMe/spring/converter/ImageKeywordConverter.java index f1de5c5..9ef5cd8 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/ImageKeywordConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/ImageKeywordConverter.java @@ -74,6 +74,14 @@ private static List> saveImageKeywordResult( + @Parameter(hidden = true) + @AuthenticationPrincipal CustomUserDetails userDetails, + @Validated @RequestBody ImageKeywordRequestDTO.ImageKeywordSaveRequestDTO request + ) { + ImageKeywordResponseDTO.ImageKeywordSaveResponseDTO result = + imageKeywordService.saveImageKeyword(userDetails.getUserId(), request.getSavedName()); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } + } \ No newline at end of file From a36d62f757364d41b2b2996d97f0450a458d89d4 Mon Sep 17 00:00:00 2001 From: bulee5328 Date: Tue, 22 Jul 2025 01:50:16 +0900 Subject: [PATCH 241/339] =?UTF-8?q?[Feature]=20PBTI=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/config/WebClientConfig.java | 14 +++ .../java/PerfumeOnMe/spring/domain/PBTI.java | 31 +++++-- .../repository/pbti/PbtiRepository.java | 9 ++ .../{Diary => diary}/DiaryService.java | 2 +- .../{Diary => diary}/DiaryServiceImpl.java | 2 +- .../service/openAi/OpenAiApiClient.java | 54 ++++++++++++ .../spring/service/openAi/OpenAiService.java | 8 ++ .../service/openAi/OpenAiServiceImpl.java | 19 +++++ .../spring/service/openAi/PromptBuilder.java | 85 +++++++++++++++++++ .../spring/service/pbti/PbtiScoringUtil.java | 74 ++++++++++++++++ .../spring/service/pbti/PbtiService.java | 10 +++ .../spring/service/pbti/PbtiServiceImpl.java | 46 ++++++++++ .../web/controller/DiaryController.java | 2 +- .../spring/web/controller/PbtiController.java | 45 ++++++++++ .../spring/web/dto/Pbti/ChatGptRequest.java | 23 +++++ .../spring/web/dto/Pbti/ChatGptResponse.java | 23 +++++ .../spring/web/dto/Pbti/PbtiRequestDTO.java | 44 ++++++++++ .../spring/web/dto/Pbti/PbtiResponseDTO.java | 83 ++++++++++++++++++ 18 files changed, 566 insertions(+), 8 deletions(-) create mode 100644 src/main/java/PerfumeOnMe/spring/config/WebClientConfig.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepository.java rename src/main/java/PerfumeOnMe/spring/service/{Diary => diary}/DiaryService.java (95%) rename src/main/java/PerfumeOnMe/spring/service/{Diary => diary}/DiaryServiceImpl.java (98%) create mode 100644 src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiApiClient.java create mode 100644 src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiService.java create mode 100644 src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiServiceImpl.java create mode 100644 src/main/java/PerfumeOnMe/spring/service/openAi/PromptBuilder.java create mode 100644 src/main/java/PerfumeOnMe/spring/service/pbti/PbtiScoringUtil.java create mode 100644 src/main/java/PerfumeOnMe/spring/service/pbti/PbtiService.java create mode 100644 src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/controller/PbtiController.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/Pbti/ChatGptRequest.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/Pbti/ChatGptResponse.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiRequestDTO.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java diff --git a/src/main/java/PerfumeOnMe/spring/config/WebClientConfig.java b/src/main/java/PerfumeOnMe/spring/config/WebClientConfig.java new file mode 100644 index 0000000..6727d0d --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/WebClientConfig.java @@ -0,0 +1,14 @@ +package PerfumeOnMe.spring.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + + @Bean + public WebClient webClient() { + return WebClient.builder().build(); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/PBTI.java b/src/main/java/PerfumeOnMe/spring/domain/PBTI.java index 25093d7..db2b654 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/PBTI.java +++ b/src/main/java/PerfumeOnMe/spring/domain/PBTI.java @@ -45,8 +45,29 @@ public class PBTI extends BaseEntity { @Column(nullable = false, length = 50) private String savedName; - @Column(columnDefinition = "json", nullable = false) - private String answers; + @Column(nullable = false, length = 150) + private String qOne; + + @Column(nullable = false, length = 150) + private String qTwo; + + @Column(nullable = false, length = 150) + private String qThree; + + @Column(nullable = false, length = 150) + private String qFour; + + @Column(nullable = false, length = 150) + private String qFive; + + @Column(nullable = false, length = 150) + private String qSix; + + @Column(nullable = false, length = 150) + private String qSeven; + + @Column(nullable = false, length = 150) + private String qEight; @Column(columnDefinition = "text", nullable = false) private String recommendation; @@ -55,16 +76,16 @@ public class PBTI extends BaseEntity { private String keywords; @Column(columnDefinition = "json", nullable = false) - private String style; + private String perfumeStyle; @Column(columnDefinition = "json", nullable = false) - private String scentProfile; + private String scentPoint; @Column(columnDefinition = "text", nullable = false) private String summary; @Column(columnDefinition = "json", nullable = false) - private String perfumes; + private String perfumeRecommend; @OneToMany(mappedBy = "pbti", cascade = CascadeType.ALL) @Builder.Default diff --git a/src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepository.java b/src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepository.java new file mode 100644 index 0000000..c7bf00b --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepository.java @@ -0,0 +1,9 @@ +package PerfumeOnMe.spring.repository.pbti; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.domain.PBTI; + +public interface PbtiRepository extends JpaRepository { + +} diff --git a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java b/src/main/java/PerfumeOnMe/spring/service/diary/DiaryService.java similarity index 95% rename from src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java rename to src/main/java/PerfumeOnMe/spring/service/diary/DiaryService.java index b162e81..2350821 100644 --- a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryService.java +++ b/src/main/java/PerfumeOnMe/spring/service/diary/DiaryService.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service.Diary; +package PerfumeOnMe.spring.service.diary; import java.time.LocalDate; import java.util.List; diff --git a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/diary/DiaryServiceImpl.java similarity index 98% rename from src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java rename to src/main/java/PerfumeOnMe/spring/service/diary/DiaryServiceImpl.java index d8bf991..5e9cf14 100644 --- a/src/main/java/PerfumeOnMe/spring/service/Diary/DiaryServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/diary/DiaryServiceImpl.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service.Diary; +package PerfumeOnMe.spring.service.diary; import java.time.LocalDate; import java.util.List; diff --git a/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiApiClient.java b/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiApiClient.java new file mode 100644 index 0000000..ff4cf2b --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiApiClient.java @@ -0,0 +1,54 @@ +package PerfumeOnMe.spring.service.openAi; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import PerfumeOnMe.spring.web.dto.Pbti.ChatGptRequest; +import PerfumeOnMe.spring.web.dto.Pbti.ChatGptResponse; +import lombok.RequiredArgsConstructor; +import reactor.core.publisher.Mono; + +@Component +@RequiredArgsConstructor +public class OpenAiApiClient { + + private static final String OPENAI_API_URL = "https://api.openai.com/v1/chat/completions"; + private final WebClient webClient; + + @Value("${openai.api-key}") + private String openAiApiKey; + @Value("${openai.model}") + private String model; // OpenAI 모델 이름 - gpt-4 + + public ChatGptResponse getChatGptResponse(ChatGptRequest request) { + return webClient.post() + .uri(OPENAI_API_URL) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + openAiApiKey) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .bodyValue(request) + .retrieve() + .bodyToMono(ChatGptResponse.class) + .onErrorResume(e -> Mono.error(new RuntimeException("OpenAI 요청 실패: " + e.getMessage()))) + .block(); + } + + public String callChatGPT(String prompt) { + ChatGptRequest request = ChatGptRequest.builder() + .model(model) + .temperature(0.7) + .messages(List.of( + new ChatGptRequest.Message("user", prompt) + )) + .build(); + + ChatGptResponse response = getChatGptResponse(request); + + return response.getChoices().get(0).getMessage().getContent(); + } + +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiService.java b/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiService.java new file mode 100644 index 0000000..bfb68dc --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiService.java @@ -0,0 +1,8 @@ +package PerfumeOnMe.spring.service.openAi; + +public interface OpenAiService { + + // PBTI 구조화된 응답 반환 + String getStructuredResponse(String prompt); +} + diff --git a/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiServiceImpl.java new file mode 100644 index 0000000..141e49e --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiServiceImpl.java @@ -0,0 +1,19 @@ +package PerfumeOnMe.spring.service.openAi; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class OpenAiServiceImpl implements OpenAiService { + + private final OpenAiApiClient openAiApiClient; // GPT API 호출용 클라이언트 + + @Override + public String getStructuredResponse(String prompt) { + return openAiApiClient.callChatGPT(prompt); // 실제 GPT 호출 로직 구현 + } +} diff --git a/src/main/java/PerfumeOnMe/spring/service/openAi/PromptBuilder.java b/src/main/java/PerfumeOnMe/spring/service/openAi/PromptBuilder.java new file mode 100644 index 0000000..7ed2141 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/openAi/PromptBuilder.java @@ -0,0 +1,85 @@ +package PerfumeOnMe.spring.service.openAi; + +import PerfumeOnMe.spring.web.dto.Pbti.PbtiRequestDTO; +import PerfumeOnMe.spring.web.dto.Pbti.PbtiResponseDTO; + +public class PromptBuilder { + + public static String buildPromptFromRequest(PbtiRequestDTO.PbtiQuestionRequest request, + PbtiResponseDTO.PbtiResult result) { + + String keywordString = String.join(", ", + result.getKeyword1(), + result.getKeyword2(), + result.getKeyword3(), + result.getKeyword4() + ); + + return String.format(""" + 아래는 사용자가 향수 성향 테스트에 응답한 결과입니다.: + Q1: %s + Q2: %s + Q3: %s + Q4: %s + Q5: %s + Q6: %s + Q7: %s + Q8: %s + + 이 사용자는 다음과 같은 향수 성향 키워드를 가지고 있습니다: + %s + + 이 키워드 각각에 대해 향수 성향 기반 설명(keywordDescription)을 작성하세요. 각 키워드는 다음 JSON 형식의 "keywords" 필드에 배열로 포함되어야 합니다. + 또한 각 keywords 배열의 "keyword"는 위의 향수 성향 키워드에서 그대로 사용하세요. GPT가 임의로 바꾸지 마세요. + + 추가로 각 질문에 대한 사용자의 답변을 아래 JSON 포맷에 맞게 분석하여 출력하세요. JSON 이외의 설명은 절대 하지 말고, JSON 데이터만 정확하게 출력해주세요.: + + - "recommendation"은 '당신은' 으로 시작되도록 하세요. + - "scentPoint" 배열 내의 "category"와 "perfumeStyle" 내 "notes" 배열 내의 "category"는 한국어로 응답해주세요. + - "scentPoint" 배열 내의 "point"는 숫자가 큰 순서대로 출력해주세요. + - "summary"는 사용자의 성격이 반영되는 단어가 들어가도록 간단하게 요약해주세요. ex) “사람들과의 에너지 흐름을 잘 이끌어내는 ~한 사람” + - "perfumeRecommend" 배열 내의 "description"은 '이 향수는' 으로 시작되도록 하고 "name"과 "brand"는 실제 존재하는 향수와 브랜드이름으로하고 영어로 응답해줘. + - "keywords" 배열에는 4개 항목을 포함하세요. + - "perfumeStyle" 내 "notes" 배열에는 5개 항목을 포함하세요. + - "scentPoint" 배열에는 5개 항목을 포함하세요. + - "perfumeRecommend" 배열에는 3개 항목을 포함하세요. + { + "recommendation": "...", + "keywords": [ + { + "keyword": "...", + "keywordDescription": "..." + } + // 4개 항목 + ], + "perfumeStyle": { + "description": "...", + "notes": [ + { + "category": "...", + "categoryDescription": "..." + } + // 5개 항목 + ] + }, + "scentPoint": [ + { + "category": "...", + "point": 1~6 범위의 정수 중 하나 + } + // 5개 항목 + ], + "summary": "...", + "perfumeRecommend": [ + { + "name": "...", + "brand": "...", + "description": "..." + } + // 3개 항목 + ] + } + """, request.getQOne(), request.getQTwo(), request.getQThree(), request.getQFour(), + request.getQFive(), request.getQSix(), request.getQSeven(), request.getQEight(), keywordString); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiScoringUtil.java b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiScoringUtil.java new file mode 100644 index 0000000..79fc7dd --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiScoringUtil.java @@ -0,0 +1,74 @@ +package PerfumeOnMe.spring.service.pbti; + +import PerfumeOnMe.spring.web.dto.Pbti.PbtiRequestDTO; +import PerfumeOnMe.spring.web.dto.Pbti.PbtiResponseDTO; + +public class PbtiScoringUtil { + + public static PbtiResponseDTO.PbtiResult calculateMbtiType(PbtiRequestDTO.PbtiQuestionRequest request) { + int eScore = 0, iScore = 0; + int sScore = 0, nScore = 0; + int tScore = 0, fScore = 0; + int jScore = 0, pScore = 0; + + // Q1~Q2 → E/I + if (request.getQOne().contains("칫솔을") || request.getQOne().contains("바로")) + eScore++; + else if (request.getQOne().contains("수건으로") || request.getQOne().contains("닦고")) + iScore++; + + if (request.getQTwo().contains("버튼") || request.getQTwo().contains("분사해")) + eScore++; + else if (request.getQTwo().contains("중간에") || request.getQTwo().contains("내리는")) + iScore++; + + // Q3~Q4 → S/N + if (request.getQThree().contains("알림처럼") || request.getQThree().contains("미리")) + sScore++; + else if (request.getQThree().contains("버스가") || request.getQThree().contains("가방에서 꺼내")) + nScore++; + + if (request.getQFour().contains("신호가 바뀌기 직전") || request.getQFour().contains("분사해")) + sScore++; + else if (request.getQFour().contains("기다리다") || request.getQFour().contains("향이 옅어지면")) + nScore++; + + // Q5~Q6 → T/F + if (request.getQFive().contains("공간") || request.getQFive().contains("퍼뜨린다")) + tScore++; + else if (request.getQFive().contains("기분") || request.getQFive().contains("헹굴 때마다")) + fScore++; + + if (request.getQSix().contains("목줄") || request.getQSix().contains("가볍게")) + tScore++; + else if (request.getQSix().contains("손목에") || request.getQSix().contains("레이어링한다")) + fScore++; + + // Q7~Q8 → J/P + if (request.getQSeven().contains("리모컨을") || request.getQSeven().contains("채널을 돌리며")) + jScore++; + else if (request.getQSeven().contains("광고가") || request.getQSeven().contains("확실히")) + pScore++; + + if (request.getQEight().contains("이불 위에서") || request.getQEight().contains("잔향을")) + jScore++; + else if (request.getQEight().contains("중앙에서") || request.getQEight().contains("톡톡")) + pScore++; + + // 키워드 결정 + String keyword1 = (eScore > iScore) ? "긍정적 임팩트를 가진 당신" + : (eScore < iScore) ? "은은한 집중형인 당신" + : "외향과 내향의 균형을 지닌 당신"; + String keyword2 = (sScore > nScore) ? "촉각에 민감한 당신" + : (sScore < nScore) ? "직관으로 이끄는 당신" + : "감각과 직관을 오가는 당신"; + String keyword3 = (tScore > fScore) ? "세부까지 놓치지 않는 당신" + : (tScore < fScore) ? "감성을 우선하는 당신" + : "사고와 감정을 조화시키는 당신"; + String keyword4 = (jScore > pScore) ? "미리 움직이는 당신" + : (jScore < pScore) ? "순간을 즐기는 당신" + : "계획과 즉흥이 공존하는 당신"; + + return new PbtiResponseDTO.PbtiResult(keyword1, keyword2, keyword3, keyword4); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiService.java b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiService.java new file mode 100644 index 0000000..198e508 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiService.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.service.pbti; + +import PerfumeOnMe.spring.web.dto.Pbti.PbtiRequestDTO; +import PerfumeOnMe.spring.web.dto.Pbti.PbtiResponseDTO; + +public interface PbtiService { + + // PBTI 결과 조회 API + PbtiResponseDTO.PbtiQuestionResponse searchPbti(Long userId, PbtiRequestDTO.PbtiQuestionRequest request); +} diff --git a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java new file mode 100644 index 0000000..3b2628a --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java @@ -0,0 +1,46 @@ +package PerfumeOnMe.spring.service.pbti; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.service.openAi.OpenAiService; +import PerfumeOnMe.spring.service.openAi.PromptBuilder; +import PerfumeOnMe.spring.web.dto.Pbti.PbtiRequestDTO; +import PerfumeOnMe.spring.web.dto.Pbti.PbtiResponseDTO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class PbtiServiceImpl implements PbtiService { + + private final OpenAiService openAiService; + private final ObjectMapper objectMapper; + + @Override + public PbtiResponseDTO.PbtiQuestionResponse searchPbti(Long userId, PbtiRequestDTO.PbtiQuestionRequest request) { + + PbtiResponseDTO.PbtiResult result = PbtiScoringUtil.calculateMbtiType(request); + + String prompt = PromptBuilder.buildPromptFromRequest(request, result); + + // GPT로부터 응답 받기 + String gptResponse = openAiService.getStructuredResponse(prompt); + + // JSON → DTO 역직렬화 + try { + return objectMapper.readValue(gptResponse, PbtiResponseDTO.PbtiQuestionResponse.class); + } catch (JsonProcessingException e) { + log.error("GPT 응답 JSON 파싱 실패. 응답: {}", gptResponse, e); + throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + } + } + +} diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java b/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java index 323fb2d..16bd6db 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java @@ -17,7 +17,7 @@ import PerfumeOnMe.spring.apiPayload.ApiResponse; import PerfumeOnMe.spring.apiPayload.code.status.SuccessStatus; import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; -import PerfumeOnMe.spring.service.Diary.DiaryService; +import PerfumeOnMe.spring.service.diary.DiaryService; import PerfumeOnMe.spring.web.dto.diary.DiaryRequestDTO; import PerfumeOnMe.spring.web.dto.diary.DiaryResponseDTO; import io.swagger.v3.oas.annotations.Operation; diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/PbtiController.java b/src/main/java/PerfumeOnMe/spring/web/controller/PbtiController.java new file mode 100644 index 0000000..4196ceb --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/controller/PbtiController.java @@ -0,0 +1,45 @@ +package PerfumeOnMe.spring.web.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; +import PerfumeOnMe.spring.service.pbti.PbtiService; +import PerfumeOnMe.spring.web.dto.Pbti.PbtiRequestDTO; +import PerfumeOnMe.spring.web.dto.Pbti.PbtiResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/pbti") +@Tag(name = "PBTI", description = "PBTI CRUD API") +public class PbtiController { + + private final PbtiService pbtiService; + + // PBTI 결과 조회 API + @PostMapping("/result") + @Operation( + summary = "PBTI 결과 조회", + description = "8개의 질문 선택지를 기반으로 PBTI 분석 결과를 조회합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "PBTI 결과가 조회되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = PbtiResponseDTO.PbtiQuestionResponse.class))) + } + ) + public ResponseEntity> searchPbti( + @RequestBody PbtiRequestDTO.PbtiQuestionRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + PbtiResponseDTO.PbtiQuestionResponse result = pbtiService.searchPbti(userDetails.getUserId(), request); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/ChatGptRequest.java b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/ChatGptRequest.java new file mode 100644 index 0000000..ed35a3e --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/ChatGptRequest.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.web.dto.Pbti; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +@AllArgsConstructor +public class ChatGptRequest { + private String model; + private List messages; + private double temperature; + + @Data + @AllArgsConstructor + public static class Message { + private String role; // "user" or "system" + private String content; + } +} diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/ChatGptResponse.java b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/ChatGptResponse.java new file mode 100644 index 0000000..d16c812 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/ChatGptResponse.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.web.dto.Pbti; + +import java.util.List; + +import lombok.Data; + +@Data +public class ChatGptResponse { + + private List choices; + + @Data + public static class Choice { + private int index; + private Message message; + } + + @Data + public static class Message { + private String role; + private String content; + } +} diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiRequestDTO.java new file mode 100644 index 0000000..a3d36e1 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiRequestDTO.java @@ -0,0 +1,44 @@ +package PerfumeOnMe.spring.web.dto.Pbti; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; + +public class PbtiRequestDTO { + + // PBTI 결과 조회 요청 DTO + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @ToString + public static class PbtiQuestionRequest { + @JsonProperty("qOne") + private String qOne; + + @JsonProperty("qTwo") + private String qTwo; + + @JsonProperty("qThree") + private String qThree; + + @JsonProperty("qFour") + private String qFour; + + @JsonProperty("qFive") + private String qFive; + + @JsonProperty("qSix") + private String qSix; + + @JsonProperty("qSeven") + private String qSeven; + + @JsonProperty("qEight") + private String qEight; + } +} diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java new file mode 100644 index 0000000..b01954f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java @@ -0,0 +1,83 @@ +package PerfumeOnMe.spring.web.dto.Pbti; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class PbtiResponseDTO { + + // PBTI 결과 조회 응답 DTO + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class PbtiQuestionResponse { + private String recommendation; + private List keywords; + private PerfumeStyle perfumeStyle; + private List scentPoint; + private String summary; + private List perfumeRecommend; + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class Keyword { + private String keyword; + private String keywordDescription; + } + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class PerfumeStyle { + private String description; + private List notes; + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class Note { + private String category; + private String categoryDescription; + } + } + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class ScentPoint { + private String category; + private int point; + } + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class PerfumeRecommend { + private String name; + private String brand; + private String description; + } + } + + // Pbti 키워드 결과 DTO + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class PbtiResult { + private String keyword1; // J/P 기준 + private String keyword2; // S/N 기준 + private String keyword3; // T/F 기준 + private String keyword4; // E/I 기준 + } +} From 1648428732d072899d4254cadfac4e3bcd1718ef Mon Sep 17 00:00:00 2001 From: bulee5328 Date: Tue, 22 Jul 2025 01:54:44 +0900 Subject: [PATCH 242/339] =?UTF-8?q?PBTI=20=EC=9D=91=EB=8B=B5=20DTO=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java index b01954f..a88d871 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java @@ -75,9 +75,9 @@ public static class PerfumeRecommend { @AllArgsConstructor @NoArgsConstructor public static class PbtiResult { - private String keyword1; // J/P 기준 - private String keyword2; // S/N 기준 - private String keyword3; // T/F 기준 - private String keyword4; // E/I 기준 + private String keyword1; + private String keyword2; + private String keyword3; + private String keyword4; } } From 1ca784e7816ca1f6c2c2d90ce378147cf626e257 Mon Sep 17 00:00:00 2001 From: bulee5328 Date: Tue, 22 Jul 2025 17:26:43 +0900 Subject: [PATCH 243/339] =?UTF-8?q?[Fix]=20=EC=97=90=EB=9F=AC=20=ED=95=B8?= =?UTF-8?q?=EB=93=A4=EB=A7=81=20GeneralException=20=EC=82=AC=EC=9A=A9=20(#?= =?UTF-8?q?73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/apiPayload/code/status/ErrorStatus.java | 4 ++++ .../PerfumeOnMe/spring/service/openAi/OpenAiApiClient.java | 4 +++- .../java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index bdf3bf1..6ef2a28 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -72,6 +72,10 @@ public enum ErrorStatus implements BaseErrorCode { USER_DIARY_NOT_FOUND(HttpStatus.BAD_REQUEST, "DIARY4003", "해당 날짜에 해당하는 다이어리를 찾을 수 없습니다."), MONTH_DIARY_NOT_FOUND(HttpStatus.BAD_REQUEST, "DIARY4004", "해당 월에 작성된 다이어리가 없습니다."), + // PBTI 에러 + CALL_WEBCLIENT_ERROR(HttpStatus.BAD_REQUEST, "PBTI4001", "WebClient 호출 과정에서 에러가 발생했습니다."), + JSON_PARSING_ERROR(HttpStatus.BAD_REQUEST, "PBTI4002", "GPT 응답 Json 파싱 과정에서 에러가 발생했습니다."), + // 예시,,, ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); diff --git a/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiApiClient.java b/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiApiClient.java index ff4cf2b..b72ec7e 100644 --- a/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiApiClient.java +++ b/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiApiClient.java @@ -8,6 +8,8 @@ import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; import PerfumeOnMe.spring.web.dto.Pbti.ChatGptRequest; import PerfumeOnMe.spring.web.dto.Pbti.ChatGptResponse; import lombok.RequiredArgsConstructor; @@ -33,7 +35,7 @@ public ChatGptResponse getChatGptResponse(ChatGptRequest request) { .bodyValue(request) .retrieve() .bodyToMono(ChatGptResponse.class) - .onErrorResume(e -> Mono.error(new RuntimeException("OpenAI 요청 실패: " + e.getMessage()))) + .onErrorResume(e -> Mono.error(new GeneralException(ErrorStatus.CALL_WEBCLIENT_ERROR))) .block(); } diff --git a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java index 3b2628a..fef3808 100644 --- a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java @@ -39,7 +39,7 @@ public PbtiResponseDTO.PbtiQuestionResponse searchPbti(Long userId, PbtiRequestD return objectMapper.readValue(gptResponse, PbtiResponseDTO.PbtiQuestionResponse.class); } catch (JsonProcessingException e) { log.error("GPT 응답 JSON 파싱 실패. 응답: {}", gptResponse, e); - throw new GeneralException(ErrorStatus._INTERNAL_SERVER_ERROR); + throw new GeneralException(ErrorStatus.JSON_PARSING_ERROR); } } From 3d9e89a32642b4f1a292d5f81d569c96094efc4c Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Wed, 23 Jul 2025 13:51:18 +0900 Subject: [PATCH 244/339] =?UTF-8?q?[Feature]=20s3=20=EA=B4=80=EB=A0=A8=20a?= =?UTF-8?q?pplication,=20deploy=ED=8C=8C=EC=9D=BC=20=EC=88=98=EC=A0=95(#80?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev_deploy.yml | 21 ++++----------------- src/main/resources/application-dev.yml | 2 +- 2 files changed, 5 insertions(+), 18 deletions(-) diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index 9b4cfdb..951a2e0 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -16,12 +16,6 @@ jobs: with: java-version: '21' distribution: 'adopt' - # - name: Copy Secret - # env: - # OCCUPY_SECRET: ${{ secrets.OCCUPY_SECRET }} - # OCCUPY_SECRET_DIR: src/main/resources - # OCCUPY_SECRET_DIR_FILE_NAME: application-secret.yml - # run: echo $OCCUPY_SECRET | base64 --decode > $OCCUPY_SECRET_DIR/$OCCUPY_SECRET_DIR_FILE_NAME - name: gradlew mod modify run: chmod +x gradlew @@ -37,7 +31,6 @@ jobs: restore-keys: | ${{ runner.os }}-gradle- - # Spring Boot 어플리케이션 BUILD(1) - name: Spring Boot Build run: ./gradlew clean build --exclude-task test @@ -75,7 +68,7 @@ jobs: username: ubuntu key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} port: ${{ secrets.EC2_SSH_PORT }} - timeout: 300s + timeout: 500s script: | sudo docker stop perfumeonme || true sudo docker rm perfumeonme || true @@ -83,15 +76,6 @@ jobs: sudo docker pull chanee29/perfumeonme echo "🚀 Starting new container with the following environment variables:" - echo "SPRING_PROFILES_ACTIVE=dev" - echo "DB_URL=${{ secrets.ENV_DB_URL }}" - echo "DB_USERNAME=${{ secrets.ENV_DB_USERNAME }}" - echo "REDIS_HOST=${{ secrets.ENV_REDIS_HOST }}" - echo "REDIS_PORT=${{ secrets.ENV_REDIS_PORT }}" - echo "JWT_SECRET=${{ secrets.ENV_JWT_SECRET }}" - echo "OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }}" - echo "KAKAO_REST_API_KEY=${{ secrets.KAKAO_REST_API_KEY }}" - echo "KAKAO_CLIENT_SECRET=${{ secrets.KAKAO_CLIENT_SECRET }}" sudo docker run -d -p 8080:8080 --name perfumeonme \ -e SPRING_PROFILES_ACTIVE=dev \ @@ -104,6 +88,9 @@ jobs: -e OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }} \ -e KAKAO_REST_API_KEY=${{ secrets.KAKAO_REST_API_KEY }} \ -e KAKAO_CLIENT_SECRET=${{ secrets.KAKAO_CLIENT_SECRET }} \ + -e AWS_S3_ACCESS_KEY_ID=${{ secrets.AWS_S3_ACCESS_KEY_ID }} \ + -e AWS_S3_SECRET_ACCESS_KEY=${{ secrets.AWS_S3_SECRET_ACCESS_KEY }} \ + -e AWS_S3_BUCKET_NAME=${{ secrets.AWS_S3_BUCKET_NAME }} \ chanee29/perfumeonme - name: Remove GitHub IP FROM security group diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 06de960..d51f21e 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -61,7 +61,7 @@ openai: cloud: aws: s3: - bucket: umc-perfume-bucket + bucket: ${AWS_S3_BUCKET_NAME} path: profile: user_profiles region: From 09438f156e398e6baf1b244bbe0a930558ca00b9 Mon Sep 17 00:00:00 2001 From: bulee5328 Date: Wed, 23 Jul 2025 21:35:48 +0900 Subject: [PATCH 245/339] =?UTF-8?q?[Feature]=20PBTI=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20API=20=EA=B5=AC=ED=98=84=20(#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apiPayload/code/status/ErrorStatus.java | 4 +- .../spring/converter/PbtiConverter.java | 18 ++++ .../repository/pbti/PbtiRepository.java | 5 +- .../repository/pbti/PbtiRepositoryCustom.java | 4 + .../repository/pbti/PbtiRepositoryImpl.java | 10 ++ .../spring/service/openAi/PromptBuilder.java | 1 + .../spring/service/pbti/PbtiService.java | 3 + .../spring/service/pbti/PbtiServiceImpl.java | 93 ++++++++++++++++++- .../spring/web/controller/PbtiController.java | 21 ++++- .../spring/web/dto/Pbti/PbtiRequestDTO.java | 7 ++ .../spring/web/dto/Pbti/PbtiResponseDTO.java | 53 +++++++++++ 11 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 src/main/java/PerfumeOnMe/spring/converter/PbtiConverter.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepositoryCustom.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepositoryImpl.java diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index 542d83b..c440f6d 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -89,7 +89,9 @@ public enum ErrorStatus implements BaseErrorCode { // PBTI 에러 CALL_WEBCLIENT_ERROR(HttpStatus.BAD_REQUEST, "PBTI4001", "WebClient 호출 과정에서 에러가 발생했습니다."), JSON_PARSING_ERROR(HttpStatus.BAD_REQUEST, "PBTI4002", "GPT 응답 Json 파싱 과정에서 에러가 발생했습니다."), - + SAVE_REDIS_ERROR(HttpStatus.BAD_REQUEST, "PBTI4003", "Redis 저장 중 직렬화 오류가 발생했습니다."), + PBTI_REDIS_KEY_EXPIRED(HttpStatus.BAD_REQUEST, "PBTI4004", "사용자의 PBTI 분석 결과가 Redis에서 만료되었거나 저장되어 있지 않습니다."), + // S3 에러 INVALID_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST, "S3IMAGE4001", "지원하지 않은 파일 확장자입니다."), diff --git a/src/main/java/PerfumeOnMe/spring/converter/PbtiConverter.java b/src/main/java/PerfumeOnMe/spring/converter/PbtiConverter.java new file mode 100644 index 0000000..c2eaea2 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/converter/PbtiConverter.java @@ -0,0 +1,18 @@ +package PerfumeOnMe.spring.converter; + +import java.time.LocalDateTime; + +import PerfumeOnMe.spring.domain.PBTI; +import PerfumeOnMe.spring.web.dto.Pbti.PbtiResponseDTO; + +public class PbtiConverter { + + // PBTI 결과 저장 API + public static PbtiResponseDTO.PbtiSaveResponse toPbtiSaveResponse(PBTI pbti) { + return PbtiResponseDTO.PbtiSaveResponse.builder() + .id(pbti.getId()) + .savedName(pbti.getSavedName()) + .createdAt(LocalDateTime.from(pbti.getCreatedAt())) + .build(); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepository.java b/src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepository.java index c7bf00b..651a2f6 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepository.java @@ -1,9 +1,12 @@ package PerfumeOnMe.spring.repository.pbti; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import PerfumeOnMe.spring.domain.PBTI; -public interface PbtiRepository extends JpaRepository { +public interface PbtiRepository extends JpaRepository, PbtiRepositoryCustom { + Optional findById(Long id); } diff --git a/src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepositoryCustom.java new file mode 100644 index 0000000..c068553 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepositoryCustom.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.repository.pbti; + +public interface PbtiRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepositoryImpl.java new file mode 100644 index 0000000..119acb2 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.repository.pbti; + +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class PbtiRepositoryImpl implements PbtiRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/service/openAi/PromptBuilder.java b/src/main/java/PerfumeOnMe/spring/service/openAi/PromptBuilder.java index 7ed2141..27b037f 100644 --- a/src/main/java/PerfumeOnMe/spring/service/openAi/PromptBuilder.java +++ b/src/main/java/PerfumeOnMe/spring/service/openAi/PromptBuilder.java @@ -35,6 +35,7 @@ public static String buildPromptFromRequest(PbtiRequestDTO.PbtiQuestionRequest r 추가로 각 질문에 대한 사용자의 답변을 아래 JSON 포맷에 맞게 분석하여 출력하세요. JSON 이외의 설명은 절대 하지 말고, JSON 데이터만 정확하게 출력해주세요.: - "recommendation"은 '당신은' 으로 시작되도록 하세요. + - "perfumeStyle" 내 "notes" 배열의 "category"를 "scentPoint" 내의 "category"에서 사용해주세요. - "scentPoint" 배열 내의 "category"와 "perfumeStyle" 내 "notes" 배열 내의 "category"는 한국어로 응답해주세요. - "scentPoint" 배열 내의 "point"는 숫자가 큰 순서대로 출력해주세요. - "summary"는 사용자의 성격이 반영되는 단어가 들어가도록 간단하게 요약해주세요. ex) “사람들과의 에너지 흐름을 잘 이끌어내는 ~한 사람” diff --git a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiService.java b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiService.java index 198e508..15b88da 100644 --- a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiService.java +++ b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiService.java @@ -7,4 +7,7 @@ public interface PbtiService { // PBTI 결과 조회 API PbtiResponseDTO.PbtiQuestionResponse searchPbti(Long userId, PbtiRequestDTO.PbtiQuestionRequest request); + + // PBTI 결과 저장 API + PbtiResponseDTO.PbtiSaveResponse savePbti(Long userId, PbtiRequestDTO.PbtiSaveRequest request); } diff --git a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java index fef3808..bac6696 100644 --- a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java @@ -1,5 +1,8 @@ package PerfumeOnMe.spring.service.pbti; +import java.time.Duration; + +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -8,8 +11,14 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.converter.PbtiConverter; +import PerfumeOnMe.spring.domain.PBTI; +import PerfumeOnMe.spring.domain.User; +import PerfumeOnMe.spring.repository.pbti.PbtiRepository; +import PerfumeOnMe.spring.repository.user.UserRepository; import PerfumeOnMe.spring.service.openAi.OpenAiService; import PerfumeOnMe.spring.service.openAi.PromptBuilder; +import PerfumeOnMe.spring.util.JsonUtils; import PerfumeOnMe.spring.web.dto.Pbti.PbtiRequestDTO; import PerfumeOnMe.spring.web.dto.Pbti.PbtiResponseDTO; import lombok.RequiredArgsConstructor; @@ -23,7 +32,11 @@ public class PbtiServiceImpl implements PbtiService { private final OpenAiService openAiService; private final ObjectMapper objectMapper; + private final PbtiRepository pbtiRepository; + private final UserRepository userRepository; + private final StringRedisTemplate stringRedisTemplate; + // PBTI 결과 조회 API @Override public PbtiResponseDTO.PbtiQuestionResponse searchPbti(Long userId, PbtiRequestDTO.PbtiQuestionRequest request) { @@ -35,12 +48,90 @@ public PbtiResponseDTO.PbtiQuestionResponse searchPbti(Long userId, PbtiRequestD String gptResponse = openAiService.getStructuredResponse(prompt); // JSON → DTO 역직렬화 + PbtiResponseDTO.PbtiQuestionResponse response; try { - return objectMapper.readValue(gptResponse, PbtiResponseDTO.PbtiQuestionResponse.class); + response = objectMapper.readValue(gptResponse, PbtiResponseDTO.PbtiQuestionResponse.class); } catch (JsonProcessingException e) { log.error("GPT 응답 JSON 파싱 실패. 응답: {}", gptResponse, e); throw new GeneralException(ErrorStatus.JSON_PARSING_ERROR); } + + // Redis에 저장할 DTO 생성 + PbtiResponseDTO.PbtiRedisDTO redisDTO = PbtiResponseDTO.PbtiRedisDTO.builder() + .qOne(request.getQOne()) + .qTwo(request.getQTwo()) + .qThree(request.getQThree()) + .qFour(request.getQFour()) + .qFive(request.getQFive()) + .qSix(request.getQSix()) + .qSeven(request.getQSeven()) + .qEight(request.getQEight()) + .recommendation(response.getRecommendation()) + .summary(response.getSummary()) + .keywords(response.getKeywords()) + .perfumeStyle(response.getPerfumeStyle()) + .scentPoint(response.getScentPoint()) + .perfumeRecommend(response.getPerfumeRecommend()) + .build(); + + // Redis에 저장 + try { + String redisKey = "pbti:result:" + userId; + String json = objectMapper.writeValueAsString(redisDTO); + stringRedisTemplate.opsForValue().set(redisKey, json, Duration.ofMinutes(15)); + } catch (JsonProcessingException e) { + throw new GeneralException(ErrorStatus.SAVE_REDIS_ERROR); + } + + return response; + } + + // PBTI 결과 저장 API + @Override + public PbtiResponseDTO.PbtiSaveResponse savePbti(Long userId, PbtiRequestDTO.PbtiSaveRequest request) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + + // Redis에서 결과 JSON 조회 + String redisKey = "pbti:result:" + userId; + String resultJson = stringRedisTemplate.opsForValue().get(redisKey); + if (resultJson == null) { + throw new GeneralException(ErrorStatus.PBTI_REDIS_KEY_EXPIRED); + } + + // JSON → DTO 역직렬화 + PbtiResponseDTO.PbtiRedisDTO redisResult; + try { + redisResult = objectMapper.readValue(resultJson, PbtiResponseDTO.PbtiRedisDTO.class); + } catch (JsonProcessingException e) { + throw new GeneralException(ErrorStatus.JSON_PARSE_ERROR); + } + + PBTI pbti = PBTI.builder() + .user(user) + .savedName(request.getSavedName()) + .qOne(redisResult.getQOne()) + .qTwo(redisResult.getQTwo()) + .qThree(redisResult.getQThree()) + .qFour(redisResult.getQFour()) + .qFive(redisResult.getQFive()) + .qSix(redisResult.getQSix()) + .qSeven(redisResult.getQSeven()) + .qEight(redisResult.getQEight()) + .recommendation(redisResult.getRecommendation()) + .summary(redisResult.getSummary()) + .keywords(JsonUtils.toJson(redisResult.getKeywords())) + .perfumeStyle(JsonUtils.toJson(redisResult.getPerfumeStyle())) + .scentPoint(JsonUtils.toJson(redisResult.getScentPoint())) + .perfumeRecommend(JsonUtils.toJson(redisResult.getPerfumeRecommend())) + .build(); + + PBTI saved = pbtiRepository.save(pbti); + + // Redis 삭제 + stringRedisTemplate.delete(redisKey); + + return PbtiConverter.toPbtiSaveResponse(saved); } } diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/PbtiController.java b/src/main/java/PerfumeOnMe/spring/web/controller/PbtiController.java index 4196ceb..600e4f1 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/PbtiController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/PbtiController.java @@ -32,7 +32,8 @@ public class PbtiController { summary = "PBTI 결과 조회", description = "8개의 질문 선택지를 기반으로 PBTI 분석 결과를 조회합니다.", responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "PBTI 결과가 조회되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = PbtiResponseDTO.PbtiQuestionResponse.class))) + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "PBTI 결과가 조회되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = PbtiResponseDTO.PbtiQuestionResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PBTI4002", description = "GPT 응답 Json 파싱 과정에서 에러가 발생했습니다.") } ) public ResponseEntity> searchPbti( @@ -42,4 +43,22 @@ public ResponseEntity> searchP PbtiResponseDTO.PbtiQuestionResponse result = pbtiService.searchPbti(userDetails.getUserId(), request); return ResponseEntity.ok(ApiResponse.onSuccess(result)); } + + // PBTI 결과 저장 API + @PostMapping("/save") + @Operation( + summary = "PBTI 결과 저장", + description = "PBTI 분석 결과를 DB에 저장합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "PBTI 결과가 저장되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = PbtiResponseDTO.PbtiSaveResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PBTI4004", description = "사용자의 PBTI 분석 결과가 Redis에서 만료되었거나 저장되어 있지 않습니다.") + } + ) + public ResponseEntity> savePbti( + @RequestBody PbtiRequestDTO.PbtiSaveRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + PbtiResponseDTO.PbtiSaveResponse result = pbtiService.savePbti(userDetails.getUserId(), request); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiRequestDTO.java index a3d36e1..1e63058 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiRequestDTO.java @@ -41,4 +41,11 @@ public static class PbtiQuestionRequest { @JsonProperty("qEight") private String qEight; } + + // PBTI 결과 저장 요청 DTO + @Getter + @Setter + public static class PbtiSaveRequest { + private String savedName; + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java index a88d871..b05d26a 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java @@ -1,7 +1,10 @@ package PerfumeOnMe.spring.web.dto.Pbti; +import java.time.LocalDateTime; import java.util.List; +import com.fasterxml.jackson.annotation.JsonProperty; + import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -80,4 +83,54 @@ public static class PbtiResult { private String keyword3; private String keyword4; } + + // Pbti 결과 저장 응답 DTO + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class PbtiSaveResponse { + private Long id; + private String savedName; + private LocalDateTime createdAt; + } + + // Redis 저장용 DTO + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class PbtiRedisDTO { + @JsonProperty("qOne") + private String qOne; + + @JsonProperty("qTwo") + private String qTwo; + + @JsonProperty("qThree") + private String qThree; + + @JsonProperty("qFour") + private String qFour; + + @JsonProperty("qFive") + private String qFive; + + @JsonProperty("qSix") + private String qSix; + + @JsonProperty("qSeven") + private String qSeven; + + @JsonProperty("qEight") + private String qEight; + + private String recommendation; + private String summary; + + private List keywords; + private PbtiQuestionResponse.PerfumeStyle perfumeStyle; + private List scentPoint; + private List perfumeRecommend; + } } From de85d1b210a89ef2b75e73ffa7ea07427f957364 Mon Sep 17 00:00:00 2001 From: bulee5328 Date: Thu, 24 Jul 2025 00:38:15 +0900 Subject: [PATCH 246/339] =?UTF-8?q?[Feature]=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20PBTI=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20(#84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/converter/PbtiConverter.java | 9 +++++++++ .../repository/pbti/PbtiRepository.java | 3 +++ .../spring/service/pbti/PbtiService.java | 3 +++ .../spring/service/pbti/PbtiServiceImpl.java | 15 +++++++++++++++ .../spring/web/controller/PbtiController.java | 17 +++++++++++++++++ .../spring/web/dto/Pbti/PbtiResponseDTO.java | 19 +++++++++++++++++++ 6 files changed, 66 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/converter/PbtiConverter.java b/src/main/java/PerfumeOnMe/spring/converter/PbtiConverter.java index c2eaea2..90f6407 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/PbtiConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/PbtiConverter.java @@ -15,4 +15,13 @@ public static PbtiResponseDTO.PbtiSaveResponse toPbtiSaveResponse(PBTI pbti) { .createdAt(LocalDateTime.from(pbti.getCreatedAt())) .build(); } + + // 마이페이지 PBTI 목록 조회 API + public static PbtiResponseDTO.PbtiListResult toPbtiListResult(PBTI pbti) { + return PbtiResponseDTO.PbtiListResult.builder() + .id(pbti.getId()) + .savedName(pbti.getSavedName()) + .createdAt(pbti.getCreatedAt()) + .build(); + } } diff --git a/src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepository.java b/src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepository.java index 651a2f6..3202ec3 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepository.java @@ -1,5 +1,6 @@ package PerfumeOnMe.spring.repository.pbti; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,4 +10,6 @@ public interface PbtiRepository extends JpaRepository, PbtiRepositoryCustom { Optional findById(Long id); + + List findAllByUserId(Long userId); } diff --git a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiService.java b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiService.java index 15b88da..b1d7802 100644 --- a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiService.java +++ b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiService.java @@ -10,4 +10,7 @@ public interface PbtiService { // PBTI 결과 저장 API PbtiResponseDTO.PbtiSaveResponse savePbti(Long userId, PbtiRequestDTO.PbtiSaveRequest request); + + // 마이페이지 PBTI 목록 조회 API + PbtiResponseDTO.SearchPbtiListResponse searchPbtiList(Long userId); } diff --git a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java index bac6696..7655d41 100644 --- a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java @@ -1,6 +1,8 @@ package PerfumeOnMe.spring.service.pbti; import java.time.Duration; +import java.util.List; +import java.util.stream.Collectors; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; @@ -134,4 +136,17 @@ public PbtiResponseDTO.PbtiSaveResponse savePbti(Long userId, PbtiRequestDTO.Pbt return PbtiConverter.toPbtiSaveResponse(saved); } + // 마이페이지 PBTI 목록 조회 API + @Override + public PbtiResponseDTO.SearchPbtiListResponse searchPbtiList(Long userId) { + List pbtiList = pbtiRepository.findAllByUserId(userId); + List results = pbtiList.stream() + .map(PbtiConverter::toPbtiListResult) + .collect(Collectors.toList()); + + return PbtiResponseDTO.SearchPbtiListResponse.builder() + .result(results) + .build(); + } + } diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/PbtiController.java b/src/main/java/PerfumeOnMe/spring/web/controller/PbtiController.java index 600e4f1..127bbdc 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/PbtiController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/PbtiController.java @@ -2,6 +2,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -61,4 +62,20 @@ public ResponseEntity> savePbti( PbtiResponseDTO.PbtiSaveResponse result = pbtiService.savePbti(userDetails.getUserId(), request); return ResponseEntity.ok(ApiResponse.onSuccess(result)); } + + // 마이페이지 PBTI 목록 조회 API + @GetMapping("/result/list") + @Operation( + summary = "마이페이지 PBTI 목록 조회", + description = "마이페이지에서 PBTI 목록을 조회합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "PBTI 목록이 조회되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = PbtiResponseDTO.PbtiListResult.class))) + } + ) + public ResponseEntity> searchPbtiList( + @AuthenticationPrincipal CustomUserDetails userDetails) { + + PbtiResponseDTO.SearchPbtiListResponse result = pbtiService.searchPbtiList(userDetails.getUserId()); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java index b05d26a..523d458 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java @@ -133,4 +133,23 @@ public static class PbtiRedisDTO { private List scentPoint; private List perfumeRecommend; } + + // 마이페이지 PBTI 목록 조회 응답 DTO + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class PbtiListResult { + private Long id; + private String savedName; + private LocalDateTime createdAt; + } + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class SearchPbtiListResponse { + private List result; + } } From e9f6e8c2fbd68ec2915ee98ac28b300289273dea Mon Sep 17 00:00:00 2001 From: bulee5328 Date: Thu, 24 Jul 2025 01:26:42 +0900 Subject: [PATCH 247/339] =?UTF-8?q?[Feature]=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20PBTI=20=EA=B2=B0=EA=B3=BC=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B5=AC=ED=98=84=20(#8?= =?UTF-8?q?6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apiPayload/code/status/ErrorStatus.java | 2 + .../spring/converter/PbtiConverter.java | 47 +++++++++++++- .../spring/service/pbti/PbtiService.java | 4 ++ .../spring/service/pbti/PbtiServiceImpl.java | 14 +++++ .../spring/web/controller/PbtiController.java | 19 ++++++ .../spring/web/dto/Pbti/PbtiRequestDTO.java | 7 +++ .../spring/web/dto/Pbti/PbtiResponseDTO.java | 62 +++++++++++++++++++ 7 files changed, 154 insertions(+), 1 deletion(-) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index c440f6d..464b19e 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -91,6 +91,8 @@ public enum ErrorStatus implements BaseErrorCode { JSON_PARSING_ERROR(HttpStatus.BAD_REQUEST, "PBTI4002", "GPT 응답 Json 파싱 과정에서 에러가 발생했습니다."), SAVE_REDIS_ERROR(HttpStatus.BAD_REQUEST, "PBTI4003", "Redis 저장 중 직렬화 오류가 발생했습니다."), PBTI_REDIS_KEY_EXPIRED(HttpStatus.BAD_REQUEST, "PBTI4004", "사용자의 PBTI 분석 결과가 Redis에서 만료되었거나 저장되어 있지 않습니다."), + PBTI_NOT_EXIST_ERROR(HttpStatus.BAD_REQUEST, "PBTI4005", "존재하지 않는 PBTI 입니다."), + PBTI_USER_NOT_MATCH(HttpStatus.BAD_REQUEST, "PBTI4006", "본인의 PBTI 결과만 조회할 수 있습니다."), // S3 에러 INVALID_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST, "S3IMAGE4001", "지원하지 않은 파일 확장자입니다."), diff --git a/src/main/java/PerfumeOnMe/spring/converter/PbtiConverter.java b/src/main/java/PerfumeOnMe/spring/converter/PbtiConverter.java index 90f6407..faea45d 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/PbtiConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/PbtiConverter.java @@ -1,12 +1,21 @@ package PerfumeOnMe.spring.converter; import java.time.LocalDateTime; +import java.util.List; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; import PerfumeOnMe.spring.domain.PBTI; import PerfumeOnMe.spring.web.dto.Pbti.PbtiResponseDTO; public class PbtiConverter { + private static final ObjectMapper objectMapper = new ObjectMapper(); + // PBTI 결과 저장 API public static PbtiResponseDTO.PbtiSaveResponse toPbtiSaveResponse(PBTI pbti) { return PbtiResponseDTO.PbtiSaveResponse.builder() @@ -24,4 +33,40 @@ public static PbtiResponseDTO.PbtiListResult toPbtiListResult(PBTI pbti) { .createdAt(pbti.getCreatedAt()) .build(); } -} + + // 마이페이지 PBTI 결과 상세 조회 API + public static PbtiResponseDTO.PbtiResultDetailResponse toPbtiResultDetailResponse(PBTI pbti) { + try { + // JSON 파싱 + List keywords = + objectMapper.readValue(pbti.getKeywords(), new TypeReference<>() { + }); + + PbtiResponseDTO.PbtiResultDetailResponse.PerfumeStyle perfumeStyle = + objectMapper.readValue(pbti.getPerfumeStyle(), + PbtiResponseDTO.PbtiResultDetailResponse.PerfumeStyle.class); + + List scentPoints = + objectMapper.readValue(pbti.getScentPoint(), new TypeReference<>() { + }); + + List perfumeRecommends = + objectMapper.readValue(pbti.getPerfumeRecommend(), new TypeReference<>() { + }); + + // DTO 반환 + return PbtiResponseDTO.PbtiResultDetailResponse.builder() + .savedName(pbti.getSavedName()) + .recommendation(pbti.getRecommendation()) + .keywords(keywords) + .perfumeStyle(perfumeStyle) + .scentPoint(scentPoints) + .summary(pbti.getSummary()) + .perfumeRecommend(perfumeRecommends) + .build(); + + } catch (JsonProcessingException e) { + throw new GeneralException(ErrorStatus.JSON_PARSE_ERROR); + } + } +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiService.java b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiService.java index b1d7802..a51dec5 100644 --- a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiService.java +++ b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiService.java @@ -13,4 +13,8 @@ public interface PbtiService { // 마이페이지 PBTI 목록 조회 API PbtiResponseDTO.SearchPbtiListResponse searchPbtiList(Long userId); + + // 마이페이지 PBTI 결과 상세 조회 API + PbtiResponseDTO.PbtiResultDetailResponse searchPbtiResult(Long userId, + PbtiRequestDTO.PbtiResultDetailRequest request); } diff --git a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java index 7655d41..1327f98 100644 --- a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java @@ -149,4 +149,18 @@ public PbtiResponseDTO.SearchPbtiListResponse searchPbtiList(Long userId) { .build(); } + // 마이페이지 PBTI 결과 상세 조회 API + @Override + public PbtiResponseDTO.PbtiResultDetailResponse searchPbtiResult(Long userId, + PbtiRequestDTO.PbtiResultDetailRequest request) { + PBTI pbti = pbtiRepository.findById(request.getPbtiId()) + .orElseThrow(() -> new GeneralException(ErrorStatus.PBTI_NOT_EXIST_ERROR)); + + if (!pbti.getUser().getId().equals(userId)) { + throw new GeneralException(ErrorStatus.PBTI_USER_NOT_MATCH); + } + + return PbtiConverter.toPbtiResultDetailResponse(pbti); + } + } diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/PbtiController.java b/src/main/java/PerfumeOnMe/spring/web/controller/PbtiController.java index 127bbdc..434641e 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/PbtiController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/PbtiController.java @@ -78,4 +78,23 @@ public ResponseEntity> searc PbtiResponseDTO.SearchPbtiListResponse result = pbtiService.searchPbtiList(userDetails.getUserId()); return ResponseEntity.ok(ApiResponse.onSuccess(result)); } + + // 마이페이지 PBTI 결과 상세 조회 API + @PostMapping("/detailResult") + @Operation( + summary = "마이페이지 PBTI 결과 상세 조회", + description = "마이페이지에서 PBTI 결과를 상세 조회합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "PBTI 결과가 상세 조회되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = PbtiResponseDTO.PbtiResultDetailResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PBTI4005", description = "존재하지 않는 PBTI 입니다.") + } + ) + public ResponseEntity> searchPbtiResult( + @RequestBody PbtiRequestDTO.PbtiResultDetailRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + PbtiResponseDTO.PbtiResultDetailResponse result = pbtiService.searchPbtiResult(userDetails.getUserId(), + request); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiRequestDTO.java index 1e63058..2f5ab31 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiRequestDTO.java @@ -48,4 +48,11 @@ public static class PbtiQuestionRequest { public static class PbtiSaveRequest { private String savedName; } + + // PBTI 결과 상세 조회 요청 DTO + @Getter + @Setter + public static class PbtiResultDetailRequest { + private Long pbtiId; + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java index 523d458..54e8491 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java @@ -152,4 +152,66 @@ public static class PbtiListResult { public static class SearchPbtiListResponse { private List result; } + + // PBTI 결과 상세 조회 응답 DTO + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class PbtiResultDetailResponse { + private String savedName; + private String recommendation; + private List keywords; + private PerfumeStyle perfumeStyle; + private List scentPoint; + private String summary; + private List perfumeRecommend; + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class Keyword { + private String keyword; + private String keywordDescription; + } + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class PerfumeStyle { + private String description; + private List notes; + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class Note { + private String category; + private String categoryDescription; + } + } + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class ScentPoint { + private String category; + private int point; + } + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class PerfumeRecommend { + private String name; + private String brand; + private String description; + } + } + } From ba6909457ac5224ab86b8015aea4a055bdc8c080 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Thu, 24 Jul 2025 22:59:21 +0900 Subject: [PATCH 248/339] =?UTF-8?q?[Feature]=20fastapi=20=EB=AF=B8?= =?UTF-8?q?=EC=A0=95=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=EC=B2=98=EB=A6=AC=20=EC=B6=94=EA=B0=80(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev_deploy.yml | 1 + .../PerfumeOnMe/spring/service/external/FastApiClient.java | 5 ++--- src/main/resources/application-dev.yml | 6 +++++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index 951a2e0..4554ee1 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -91,6 +91,7 @@ jobs: -e AWS_S3_ACCESS_KEY_ID=${{ secrets.AWS_S3_ACCESS_KEY_ID }} \ -e AWS_S3_SECRET_ACCESS_KEY=${{ secrets.AWS_S3_SECRET_ACCESS_KEY }} \ -e AWS_S3_BUCKET_NAME=${{ secrets.AWS_S3_BUCKET_NAME }} \ + -e EXTERNAL_FASTAPI_RECOMMEND_URL=http://dummy-url.com \ chanee29/perfumeonme - name: Remove GitHub IP FROM security group diff --git a/src/main/java/PerfumeOnMe/spring/service/external/FastApiClient.java b/src/main/java/PerfumeOnMe/spring/service/external/FastApiClient.java index a54e3d4..ced6620 100644 --- a/src/main/java/PerfumeOnMe/spring/service/external/FastApiClient.java +++ b/src/main/java/PerfumeOnMe/spring/service/external/FastApiClient.java @@ -9,8 +9,6 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; -import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; -import PerfumeOnMe.spring.apiPayload.exception.GeneralException; import PerfumeOnMe.spring.web.dto.external.FastApiRecommendRequest; import PerfumeOnMe.spring.web.dto.external.FastApiRecommendResponse; import lombok.RequiredArgsConstructor; @@ -37,7 +35,8 @@ public FastApiRecommendResponse getFullRecommendation(FastApiRecommendRequest re return response.getBody(); } catch (Exception e) { - throw new GeneralException(ErrorStatus.FASTAPI_COMMUNICATION_ERROR); + // throw new GeneralException(ErrorStatus.FASTAPI_COMMUNICATION_ERROR); + return new FastApiRecommendResponse(); //임시조치 : FAST API 서버 없을 때 기본응답반환으로 서버 유지 } } } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index d51f21e..f511f60 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -70,4 +70,8 @@ cloud: auto: false credentials: accessKey: ${AWS_S3_ACCESS_KEY_ID} - secretKey: ${AWS_S3_SECRET_ACCESS_KEY} \ No newline at end of file + secretKey: ${AWS_S3_SECRET_ACCESS_KEY} + +external: + fastapi: + recommend-url: ${EXTERNAL_FASTAPI_RECOMMEND_URL:http://dummy-url.com} # 환경변수 없으면 기본값 사용(dummy-url.com) \ No newline at end of file From e144b029a688c555e08442a66c5f67881a4754ac Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Fri, 25 Jul 2025 16:04:31 +0900 Subject: [PATCH 249/339] =?UTF-8?q?[Fix]=20=EA=B2=B0=EA=B3=BC=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=20=EA=B0=9C=EC=88=98=20=EC=88=98=EC=A0=95=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/repository/fragrance/FragranceRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepository.java b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepository.java index 23732dc..adedd12 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepository.java @@ -48,7 +48,7 @@ WHEN COALESCE(match_note.match_cnt, 0) = 2 THEN 2 WHEN COALESCE(match_note.match_cnt, 0) = 1 THEN 3 ELSE 4 END ASC - LIMIT 6 + LIMIT 9 """, nativeQuery = true) List findByUserMdChoice(@Param("gender") String gender, @Param("noteIdList") List userNoteIdList); From 8238ad67328dbd398eef7a70c14e799afc65f102 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Fri, 25 Jul 2025 22:59:02 +0900 Subject: [PATCH 250/339] =?UTF-8?q?[Feature]=20=ED=96=A5=EC=88=98=EA=B3=B5?= =?UTF-8?q?=EB=B0=A9=20=EA=B2=B0=EA=B3=BC=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9E=88=20DTO,=20Controller?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/controller/WorkshopController.java | 55 +++++++++++++++++++ .../web/dto/workshop/WorkshopRequestDTO.java | 4 ++ .../web/dto/workshop/WorkshopResponseDTO.java | 21 +++++++ 3 files changed, 80 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopRequestDTO.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java b/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java new file mode 100644 index 0000000..0bde7c5 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java @@ -0,0 +1,55 @@ +package PerfumeOnMe.spring.web.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; +import PerfumeOnMe.spring.service.workshop.WorkshopService; +import PerfumeOnMe.spring.web.dto.workshop.WorkshopResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/workshop") +@Tag(name = "Workshop", description = "향수공방 API") +public class WorkshopController { + + private final WorkshopService workshopService; + + /** 향수공방 목록 조회*/ + @GetMapping("/result/list") + @Operation( + summary = "향수공방 목록 조회 (마이페이지)", + description = "해당 유저가 저장한 향수공방 결과 목록을 조회합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON200", + description = "요청에 성공하였습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = WorkshopResponseDTO.WorkshopListResponseDTO.class) + ) + ) + } + ) + public ResponseEntity>> getWorkshopList( + @Parameter(hidden = true) + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + return ResponseEntity.ok(ApiResponse.onSuccess(workshopService + .findAllWorkshopsByUser( + userDetails))); + } + +} diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopRequestDTO.java new file mode 100644 index 0000000..5b65fe9 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopRequestDTO.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.web.dto.workshop; + +public class WorkshopRequestDTO { +} diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java new file mode 100644 index 0000000..dd44ff0 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java @@ -0,0 +1,21 @@ +package PerfumeOnMe.spring.web.dto.workshop; + +import java.time.LocalDateTime; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +@Schema(description = "향수공방 응답 DTO") +public class WorkshopResponseDTO { + + @Builder + @Getter + @Schema(description = "마이페이지 향수공방 목록 응답") + public static class WorkshopListResponseDTO { + private Long workshopId; + private String savedName; + private LocalDateTime createdAt; + } + +} From aa53b337683420004bca921a1414a20de5cc677b Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Fri, 25 Jul 2025 23:00:39 +0900 Subject: [PATCH 251/339] =?UTF-8?q?[Feature]=20=ED=96=A5=EC=88=98=EA=B3=B5?= =?UTF-8?q?=EB=B0=A9=20=EA=B2=B0=EA=B3=BC=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EA=B5=AC=ED=98=84,?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=98=88=EC=99=B8=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apiPayload/code/status/ErrorStatus.java | 1 + .../spring/converter/WorkshopConverter.java | 22 +++++++++ .../workshop/WorkshopRepository.java | 13 +++++ .../service/workshop/WorkshopService.java | 47 +++++++++++++++++++ 4 files changed, 83 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java create mode 100644 src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopRepository.java create mode 100644 src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index 464b19e..50c6770 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -24,6 +24,7 @@ public enum ErrorStatus implements BaseErrorCode { LOGIN_PARSING_FAIL(HttpStatus.BAD_REQUEST, "MEMBER4004", "로그인 DTO 변환을 실패했습니다."), LOGIN_UNKNOWN_ERROR(HttpStatus.BAD_REQUEST, "MEMBER4005", "로그인 중 알 수 없는 오류가 발생했습니다."), NICKNAME_DUPLICATE(HttpStatus.BAD_REQUEST, "MEMBER4006", "이미 사용된 닉네임입니다."), + USER_ID_NULL(HttpStatus.UNAUTHORIZED, "MEMBER4007", "유저 정보가 존재 하지 않습니다. "), // 토큰 에러 INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "TOKEN4001", "유효하지 않은 토큰입니다."), diff --git a/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java b/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java new file mode 100644 index 0000000..689b009 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java @@ -0,0 +1,22 @@ +package PerfumeOnMe.spring.converter; + +import java.util.List; + +import PerfumeOnMe.spring.domain.Workshop; +import PerfumeOnMe.spring.web.dto.workshop.WorkshopResponseDTO; + +public class WorkshopConverter { + + /** 향수공방 목록 converter*/ + public static List toWorkshopListResponse( + List workshops + ) { + return workshops.stream() + .map(workshop -> WorkshopResponseDTO.WorkshopListResponseDTO.builder() + .workshopId(workshop.getId()) + .savedName(workshop.getSavedName()) + .createdAt(workshop.getCreatedAt()) + .build()) + .toList(); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopRepository.java b/src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopRepository.java new file mode 100644 index 0000000..8fb27db --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopRepository.java @@ -0,0 +1,13 @@ +package PerfumeOnMe.spring.repository.workshop; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.domain.User; +import PerfumeOnMe.spring.domain.Workshop; + +public interface WorkshopRepository extends JpaRepository { + + List findAllByUser(User user); +} diff --git a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java b/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java new file mode 100644 index 0000000..b567ece --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java @@ -0,0 +1,47 @@ +package PerfumeOnMe.spring.service.workshop; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; +import PerfumeOnMe.spring.converter.WorkshopConverter; +import PerfumeOnMe.spring.domain.User; +import PerfumeOnMe.spring.domain.Workshop; +import PerfumeOnMe.spring.repository.user.UserRepository; +import PerfumeOnMe.spring.repository.workshop.WorkshopRepository; +import PerfumeOnMe.spring.service.user.UserService; +import PerfumeOnMe.spring.web.dto.workshop.WorkshopResponseDTO; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class WorkshopService { + private final WorkshopRepository workshopRepository; + private final UserRepository userRepository; + private final UserService userService; + + @Transactional(readOnly = true) + public List findAllWorkshopsByUser(CustomUserDetails userDetails) { + + Long userId = userDetails.getUserId(); + + // 유저 ID 검증 + if (userId == null) { + throw new GeneralException(ErrorStatus.USER_ID_NULL); + } + + // 유저 존재 여부 검증 + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + + // 응답생성 + List workshops = workshopRepository.findAllByUser(user); + + return WorkshopConverter.toWorkshopListResponse(workshops); + } + +} From 66e6a91a4fb065f907be99b87c37f29ca93c52a1 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Fri, 25 Jul 2025 23:01:55 +0900 Subject: [PATCH 252/339] =?UTF-8?q?[Feature]=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EC=84=A4=EB=AA=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/web/controller/ImageKeywordController.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java b/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java index f986285..fe07410 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java @@ -37,8 +37,7 @@ public class ImageKeywordController { @GetMapping("/result/list") @Operation( summary = "이미지 키워드 목록 조회 (마이페이지)", - description = "해당 유저가 저장한 이미지 키워드 결과 목록을 조회합니다.\n\n" + - "마이페이지 내 '추천 결과' 영역에 출력되는 카드들의 리스트 데이터입니다.", + description = "해당 유저가 저장한 이미지 키워드 결과 목록을 조회합니다.", responses = { @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "COMMON200", From 312df58c0f3c831dba1be367953cc4cfca9cb302 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sat, 26 Jul 2025 14:30:57 +0900 Subject: [PATCH 253/339] =?UTF-8?q?[feature]=20=ED=96=A5=EC=88=98=EA=B3=B5?= =?UTF-8?q?=EB=B0=A9=20=EC=83=81=EC=84=B8=EC=A1=B0=ED=9A=8C=20DTO,=20Contr?= =?UTF-8?q?oller=20(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/controller/WorkshopController.java | 40 ++++++++++++++ .../web/dto/workshop/WorkshopResponseDTO.java | 55 +++++++++++++++++++ 2 files changed, 95 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java b/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java index 0bde7c5..8d32cae 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java @@ -5,6 +5,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -52,4 +53,43 @@ public ResponseEntity> getWorkshopDetail( + @Parameter(description = "조회할 향수공방 결과 ID", required = true, example = "1") + @PathVariable Long workshopId, + @Parameter(hidden = true) + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + return ResponseEntity.ok(ApiResponse.onSuccess(workshopService + .findWorkshopById(workshopId, + userDetails))); + } + } diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java index dd44ff0..14032b2 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java @@ -1,10 +1,15 @@ package PerfumeOnMe.spring.web.dto.workshop; import java.time.LocalDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; @Schema(description = "향수공방 응답 DTO") public class WorkshopResponseDTO { @@ -18,4 +23,54 @@ public static class WorkshopListResponseDTO { private LocalDateTime createdAt; } + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "향수공방 결과 상세조회") + public static class WorkshopDetailResponseDTO { + + @Schema(description = "키워드 요약", example = "#상큼한첫인상 #감정전달향 #무디한마무리") + private String keywordSummary; + + @Schema(description = "첫인상 설명", example = "상큼한 시트러스가 먼저 퍼지며, 활기차고 개방적인 에너지를 전달합니다...") + private String firstImpression; + + @Schema(description = "중간 인상 설명", example = "곧이어 재스민의 은은한 꽃향기가 중심을 잡습니다...") + private String centerImpression; + + @Schema(description = "마지막 인상 설명", example = "이 향기의 핵심은 단연 우디 노트입니다...") + private String lastImpression; + + @Schema(description = "성향 분석", example = "🌞 겉으로는 밝고 유쾌하며 누구든 쉽게 다가갈 수 있는 사람...") + private String tendency; + + @Schema(description = "추천 향수 목록") + @JsonProperty("recommendedFragranceJson") + private List recommendedFragranceJson; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "추천 향수 정보") + public static class RecommendedFragranceDTO { + + @Schema(description = "브랜드명", example = "딥디크") + private String brand; + + @Schema(description = "향수명", example = "탐다오") + private String name; + + @Schema(description = "향수 설명", example = "백단향의 고요한 잔향이 매력적인 향수") + private String description; + + @Schema(description = "향수 가격", example = "30000") + private int price; + + @Schema(description = "이미지 URL", example = "www.s3.com") + private String imageUrl; + } + } From 9c6e380b3b78feb003171589a49d189685aca71c Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sat, 26 Jul 2025 14:34:12 +0900 Subject: [PATCH 254/339] =?UTF-8?q?[feature]=20=ED=96=A5=EC=88=98=EA=B3=B5?= =?UTF-8?q?=EB=B0=A9=20=EC=83=81=EC=84=B8=EC=A1=B0=ED=9A=8C=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EA=B5=AC=ED=98=84(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apiPayload/code/status/ErrorStatus.java | 6 +++- .../spring/converter/WorkshopConverter.java | 36 +++++++++++++++++++ .../workshop/WorkshopRepository.java | 3 ++ .../service/workshop/WorkshopService.java | 26 ++++++++++++++ 4 files changed, 70 insertions(+), 1 deletion(-) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index 50c6770..b39f445 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -71,7 +71,7 @@ public enum ErrorStatus implements BaseErrorCode { INVALID_IMAGEKEYWORD_VALUE(HttpStatus.BAD_REQUEST, "IMAGEKEYWORD4001", "잘못된 키워드값입니다. 키워드 값을 확인해주세요."), EXPIRED_IMAGEKEYWORD_RESULT(HttpStatus.REQUEST_TIMEOUT, "IMAGEKEYWORD4002", "생성할 이미지 키워드 결과가 만료되었습니다."), ALREADY_KEYWORD_NAME(HttpStatus.BAD_REQUEST, "IMAGEKEYWORD4003", "동일한 이름으로 저장된 결과가 존재합니다."), - INVALID_IMAGEKEYWORD_ID(HttpStatus.BAD_REQUEST, "IMAGEKEYWORD4004", "해당 ID의 이미지 결과가 존재하지 않습니다."), + INVALID_IMAGEKEYWORD_ID(HttpStatus.BAD_REQUEST, "IMAGEKEYWORD4004", "해당 ID의 이미지 결과를 조회할 수 없습니다."), // FastAPI 연동 에러 FASTAPI_COMMUNICATION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "FASTAPI5001", "FastAPI 서버 통신 중 오류가 발생했습니다."), @@ -98,6 +98,10 @@ public enum ErrorStatus implements BaseErrorCode { // S3 에러 INVALID_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST, "S3IMAGE4001", "지원하지 않은 파일 확장자입니다."), + // 향수공방 에러 + WORKSHOP_USER_NOT_MATCH(HttpStatus.BAD_REQUEST, "WORKSHOP4004", "사용자 본인의 향수공방 결과만 조회할 수 있습니다."), + WORKSHOP_ID_NULL(HttpStatus.UNAUTHORIZED, "WORKSHOP4005", "향수공방 결과 정보가 존재하지 않습니다"), + // 예시,,, ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); diff --git a/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java b/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java index 689b009..7cc1287 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java @@ -1,7 +1,12 @@ package PerfumeOnMe.spring.converter; +import java.util.ArrayList; import java.util.List; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + import PerfumeOnMe.spring.domain.Workshop; import PerfumeOnMe.spring.web.dto.workshop.WorkshopResponseDTO; @@ -19,4 +24,35 @@ public static List toWorkshopListRe .build()) .toList(); } + + public static WorkshopResponseDTO.WorkshopDetailResponseDTO toWorkshopDetailResponse(Workshop workshop) { + // 추천 향수 리스트 JSON 파싱 + List recommendedFragranceDTOList = + parseFragranceJson(workshop.getRecommendedFragranceJson()); + + return WorkshopResponseDTO.WorkshopDetailResponseDTO.builder() + .keywordSummary(workshop.getKeywordSummary()) + .firstImpression(workshop.getFirstImpression()) + .centerImpression(workshop.getCenterImpression()) + .lastImpression(workshop.getLastImpression()) + .tendency(workshop.getTendency()) + .recommendedFragranceJson(recommendedFragranceDTOList) + .build(); + } + + /** JSON 문자열을 추천 향수 DTO 리스트로 변환 */ + private static List parseFragranceJson(String fragranceJson) { + if (fragranceJson == null || fragranceJson.trim().isEmpty()) { + return new ArrayList<>(); + } + + try { + ObjectMapper objectMapper = new ObjectMapper(); + return objectMapper.readValue(fragranceJson, + new TypeReference>() { + }); + } catch (JsonProcessingException e) { + return new ArrayList<>(); + } + } } diff --git a/src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopRepository.java b/src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopRepository.java index 8fb27db..4bb23a4 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopRepository.java @@ -1,6 +1,7 @@ package PerfumeOnMe.spring.repository.workshop; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -10,4 +11,6 @@ public interface WorkshopRepository extends JpaRepository { List findAllByUser(User user); + + Optional findByIdAndUser(Long id, User user); } diff --git a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java b/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java index b567ece..d3f79f6 100644 --- a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java +++ b/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java @@ -44,4 +44,30 @@ public List findAllWorkshopsByUser( return WorkshopConverter.toWorkshopListResponse(workshops); } + @Transactional(readOnly = true) + public WorkshopResponseDTO.WorkshopDetailResponseDTO findWorkshopById( + Long workshopId, CustomUserDetails userDetails) { + Long userId = userDetails.getUserId(); + + // 유저 ID NULL 검증 + if (userId == null) { + throw new GeneralException(ErrorStatus.USER_ID_NULL); + } + + // 유저 존재 여부 검증 + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + + // 향수 공방 존재 여부 검증 + if (!workshopRepository.existsById(workshopId)) { + throw new GeneralException(ErrorStatus.WORKSHOP_ID_NULL); + } + // 사용자의 향수공방 여부 검증 + Workshop workshop = workshopRepository.findByIdAndUser(workshopId, user) + .orElseThrow(() -> new GeneralException(ErrorStatus.WORKSHOP_USER_NOT_MATCH)); + + // 응답 생성 + return WorkshopConverter.toWorkshopDetailResponse(workshop); + } + } From 12538aa4151456298e9388d27951814af49a965c Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sat, 26 Jul 2025 15:22:13 +0900 Subject: [PATCH 255/339] =?UTF-8?q?[feature]=20=ED=96=A5=EC=88=98=EA=B3=B5?= =?UTF-8?q?=EB=B0=A9=20=EC=A1=B0=ED=9A=8C=20=EC=97=90=EB=9F=AC=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/apiPayload/code/status/ErrorStatus.java | 4 ++-- .../spring/web/controller/WorkshopController.java | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index b39f445..95d8a12 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -99,8 +99,8 @@ public enum ErrorStatus implements BaseErrorCode { INVALID_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST, "S3IMAGE4001", "지원하지 않은 파일 확장자입니다."), // 향수공방 에러 - WORKSHOP_USER_NOT_MATCH(HttpStatus.BAD_REQUEST, "WORKSHOP4004", "사용자 본인의 향수공방 결과만 조회할 수 있습니다."), - WORKSHOP_ID_NULL(HttpStatus.UNAUTHORIZED, "WORKSHOP4005", "향수공방 결과 정보가 존재하지 않습니다"), + WORKSHOP_USER_NOT_MATCH(HttpStatus.BAD_REQUEST, "WORKSHOP4004", "해당 향수공방 결과에 접근할 수 없습니다."), + WORKSHOP_ID_NULL(HttpStatus.UNAUTHORIZED, "WORKSHOP4005", "해당 향수공방 결과 정보가 존재하지 않습니다."), // 예시,,, ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java b/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java index 8d32cae..694bf95 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java @@ -72,12 +72,12 @@ public ResponseEntity Date: Sat, 26 Jul 2025 17:48:43 +0900 Subject: [PATCH 256/339] =?UTF-8?q?[Fix]=20@NotNull=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/config/security/auth/dto/AuthRequestDTO.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthRequestDTO.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthRequestDTO.java index aee81f8..17c631e 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthRequestDTO.java @@ -1,5 +1,6 @@ package PerfumeOnMe.spring.config.security.auth.dto; +import jakarta.validation.constraints.NotNull; import lombok.Getter; import lombok.NoArgsConstructor; @@ -8,7 +9,9 @@ public class AuthRequestDTO { @Getter @NoArgsConstructor public static class Login { + @NotNull private String loginId; + @NotNull private String password; } } From 2feac4532392bcce06a6fbe786a41efcb23b4f3d Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Sat, 26 Jul 2025 17:49:49 +0900 Subject: [PATCH 257/339] =?UTF-8?q?[Fix]=20Login=20Filter=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EC=A3=BC=EC=84=9D?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/config/security/SecurityConfig.java | 6 +++--- .../spring/config/security/auth/filter/JwtLoginFilter.java | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java b/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java index 0b2f72e..e85d0c9 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java @@ -51,8 +51,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .exceptionHandling(exception -> exception .authenticationEntryPoint(JwtAuthenticationEntryPoint) .accessDeniedHandler(JwtAccessDeniedHandler)) - // filter 추가 및 수정 - .addFilterAt(jwtLoginFilter, UsernamePasswordAuthenticationFilter.class) + // filter 추가 + // .addFilterAt(jwtLoginFilter, UsernamePasswordAuthenticationFilter.class) // 로그인 필터 제거 .addFilterBefore(JwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(JwtExceptionHandlerFilter, JwtAuthenticationFilter.class) // Session 관련 설정 - 소셜 로그인 과정에서 필요할까봐 IF_REQUIRED로 설정 @@ -72,7 +72,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); - config.setAllowedOrigins(List.of("*")); // spring: [localhost:8080, localhost:5000], localhost:8081 + config.setAllowedOrigins(List.of("*")); // spring: [localhost:8080, localhost:5000], localhost:3000 config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedHeaders(List.of("Authorization", "Refresh-Token", "Content-Type")); config.setAllowCredentials(false); // origin 바꾸면 true로 설정 diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java index e0d84aa..9ed3233 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java @@ -53,7 +53,7 @@ public class JwtLoginFilter extends UsernamePasswordAuthenticationFilter { @Autowired public void setAuthenticationManager(AuthenticationManager authenticationManager) { super.setAuthenticationManager(authenticationManager); - setFilterProcessesUrl("/auth/login"); + setFilterProcessesUrl("/auth/login/filter"); // 접근하지 못하게 경로 수정 } // 인증 시도 From feea328077979cc9b705449ed6dfd1fee4316c26 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Sat, 26 Jul 2025 17:51:30 +0900 Subject: [PATCH 258/339] =?UTF-8?q?[Refactor]=20Swagger=EC=97=90=EC=84=9C?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=ED=95=A0=20=EC=88=98=20=EC=9E=88?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20Login=20Controller=20=EB=B0=8F=20Service?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/LoginController.java | 44 +++++++++ .../security/auth/service/LoginService.java | 13 +++ .../auth/service/LoginServiceImpl.java | 89 +++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/auth/controller/LoginController.java create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/auth/service/LoginService.java create mode 100644 src/main/java/PerfumeOnMe/spring/config/security/auth/service/LoginServiceImpl.java diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/controller/LoginController.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/controller/LoginController.java new file mode 100644 index 0000000..5ba12b9 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/controller/LoginController.java @@ -0,0 +1,44 @@ +package PerfumeOnMe.spring.config.security.auth.controller; + +import java.io.IOException; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.config.security.auth.dto.AuthRequestDTO; +import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.config.security.auth.service.LoginService; +import PerfumeOnMe.spring.web.dto.user.UserResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class LoginController { + + private final LoginService loginService; + + @PostMapping("/login") + @Operation( + summary = "로그인 API", + description = "사용자의 아이디와 비밀번호로 로그인을 진행하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다.", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserResponseDTO.SignupResult.class))), + } + ) + public ResponseEntity> login( + @RequestBody @Valid AuthRequestDTO.Login loginRequest, HttpServletResponse response) throws IOException { + AuthResponseDTO.LoginResult loginResult = loginService.login(loginRequest, response); + return ResponseEntity.ok().body(ApiResponse.onSuccess(loginResult)); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/service/LoginService.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/service/LoginService.java new file mode 100644 index 0000000..f36eb37 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/service/LoginService.java @@ -0,0 +1,13 @@ +package PerfumeOnMe.spring.config.security.auth.service; + +import java.io.IOException; + +import PerfumeOnMe.spring.config.security.auth.dto.AuthRequestDTO; +import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; +import jakarta.servlet.http.HttpServletResponse; + +public interface LoginService { + + public AuthResponseDTO.LoginResult login(AuthRequestDTO.Login request, HttpServletResponse response) throws + IOException; +} diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/service/LoginServiceImpl.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/service/LoginServiceImpl.java new file mode 100644 index 0000000..2b0f049 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/service/LoginServiceImpl.java @@ -0,0 +1,89 @@ +package PerfumeOnMe.spring.config.security.auth.service; + +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.config.security.auth.converter.AuthConverter; +import PerfumeOnMe.spring.config.security.auth.dto.AuthRequestDTO; +import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.config.security.auth.manager.LogoutAccessTokenManager; +import PerfumeOnMe.spring.config.security.auth.manager.RefreshTokenManager; +import PerfumeOnMe.spring.config.security.auth.provider.CustomLoginAuthenticationProvider; +import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; +import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; +import PerfumeOnMe.spring.domain.enums.Social; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@Slf4j +@Transactional +@RequiredArgsConstructor +public class LoginServiceImpl implements LoginService { + + private final CustomLoginAuthenticationProvider customLoginAuthenticationProvider; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenManager refreshTokenManager; + private final LogoutAccessTokenManager logoutAccessTokenManager; + + @Override + public AuthResponseDTO.LoginResult login(AuthRequestDTO.Login requestDTO, HttpServletResponse response) { + + // 인증 시도 및 Authentication 획득 + UsernamePasswordAuthenticationToken loginRequest = UsernamePasswordAuthenticationToken + .unauthenticated(requestDTO.getLoginId(), requestDTO.getPassword()); + Authentication authResult; + try { + authResult = customLoginAuthenticationProvider.authenticate(loginRequest); + } catch (BadCredentialsException failed) { + throw new GeneralException(ErrorStatus.PASSWORD_NOT_MATCH); + } catch (UsernameNotFoundException failed) { + throw new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND); + } catch (Exception failed) { + throw new GeneralException(ErrorStatus.LOGIN_UNKNOWN_ERROR); + } + + // Authentication에서 principal String 추출 + String loginId = authResult.getName(); + + // 사용자의 로그아웃 액세스 토큰이 존재하는 경우 삭제 + if (logoutAccessTokenManager.findLogoutAccessToken(loginId)) { + logoutAccessTokenManager.deleteLogoutAccessToken(loginId); + } + + // SecurityContextHolder에 인증 설정 + SecurityContextHolder.getContext().setAuthentication(authResult); + + return generateAuthResponse(loginId, authResult, Social.LOCAL, response); + } + + public AuthResponseDTO.LoginResult generateAuthResponse(String loginId, + Authentication request, Social social, HttpServletResponse response) { + + // Authentication에서 userId 추출 + Long userId = ((CustomUserDetails)request.getPrincipal()).getUserId(); + + // 토큰 생성 및 DTO에 담기 + String accessToken = jwtTokenProvider.createAccessToken(request); + String refreshToken = jwtTokenProvider.createRefreshToken(request); + + // 새로 발급한 리프레시 토큰을 Redis에 저장 - 덮어씌우기 + refreshTokenManager.saveRefreshToken(loginId, refreshToken); + + // 응답 헤더 작성 + response.setCharacterEncoding("UTF-8"); + response.setContentType("application/json"); + response.setStatus(HttpServletResponse.SC_OK); + response.setHeader("Authorization", "Bearer " + accessToken); + + return AuthConverter.toLoginResult(refreshToken, userId, social); + } +} From 6e7f672bd0168828fa449a56485b1d59cb504e0c Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Sat, 26 Jul 2025 17:51:37 +0900 Subject: [PATCH 259/339] =?UTF-8?q?[Fix]=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20API=20service=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EB=A9=94=EC=84=9C=EB=93=9C=EB=A1=9C=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/service/user/UserServiceImpl.java | 23 +++++-------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java index 0c94b15..ba8ce3b 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java @@ -14,11 +14,12 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.config.security.auth.converter.AuthConverter; import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; import PerfumeOnMe.spring.config.security.auth.manager.LogoutAccessTokenManager; import PerfumeOnMe.spring.config.security.auth.manager.RefreshTokenManager; import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; +import PerfumeOnMe.spring.config.security.auth.service.LoginService; +import PerfumeOnMe.spring.config.security.auth.service.LoginServiceImpl; import PerfumeOnMe.spring.config.security.auth.token.JwtAuthenticationToken; import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; import PerfumeOnMe.spring.converter.FragranceConverter; @@ -56,6 +57,7 @@ public class UserServiceImpl implements UserService { private final UserNoteRepository userNoteRepository; private final NoteRepository noteRepository; private final UserFragranceRepository userFragranceRepository; + private final LoginService loginService; // 사용자 회원가입 @Override @@ -93,27 +95,14 @@ public AuthResponseDTO.LoginResult reissue(String reqRefreshToken, HttpServletRe // 리프레시 토큰에서 Subject 추출 String loginId = jwtTokenProvider.getSubject(reqRefreshToken); + UserDetails userDetails = userDetailsService.loadUserByUsername(loginId); // 토큰 생성 및 DTO에 담기 - UserDetails userDetails = userDetailsService.loadUserByUsername(loginId); Social social = ((CustomUserDetails)userDetails).getSocial(); JwtAuthenticationToken request = new JwtAuthenticationToken( userDetails, null, userDetails.getAuthorities(), social); - String accessToken = jwtTokenProvider.createAccessToken(request); - String refreshToken = jwtTokenProvider.createRefreshToken(request); - Long userId = ((CustomUserDetails)userDetails).getUserId(); - AuthResponseDTO.LoginResult loginResultDTO = AuthConverter.toLoginResult(refreshToken, userId, social); - - // 새로 발급한 리프레시 토큰을 Redis에 저장 - 덮어씌우기 - refreshTokenManager.saveRefreshToken(loginId, refreshToken); - - // 응답 헤더 작성 - response.setCharacterEncoding("UTF-8"); - response.setContentType("application/json"); - response.setStatus(HttpServletResponse.SC_OK); - response.setHeader("Authorization", "Bearer " + accessToken); - - return loginResultDTO; + return ((LoginServiceImpl)loginService) + .generateAuthResponse(loginId, request, social, response); } // 사용자 로그아웃 - 액세스 토큰과 리프레시 토큰 블랙리스트화 From d19a8a4e271f25210e1f981c4fe6cf926060d2f0 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Sat, 26 Jul 2025 22:06:41 +0900 Subject: [PATCH 260/339] =?UTF-8?q?[Fix]=20kakao=20loginId=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20prefix=20=EC=88=98=EC=A0=95=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/config/security/oauth/service/KakaoService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/KakaoService.java b/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/KakaoService.java index 38dda96..476171f 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/KakaoService.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/KakaoService.java @@ -50,7 +50,7 @@ public AuthResponseDTO.LoginResult oAuthLogin(String code, HttpServletResponse r // 이미 가입한 사용자라면 꺼내고, 아니라면 회원가입 진행 User user = userRepository.findUserByLoginId(email).orElseGet(() -> { User newUser = OAuthConverter.toSignupUser( - Social.KAKAO, "kakao" + email, name, "password", imageUrl, nickname); + Social.KAKAO, email, name, "password", imageUrl, nickname); return userRepository.save(newUser); }); From 0f4d90e6e6cb7c81c319c56310e4b336c6b8a802 Mon Sep 17 00:00:00 2001 From: bulee5328 Date: Sat, 26 Jul 2025 22:47:19 +0900 Subject: [PATCH 261/339] =?UTF-8?q?[Refactor]=20PBTI=20=EC=A1=B0=ED=9A=8C,?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=EC=A1=B0=ED=9A=8C=20API=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#92)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/external/FastApiClient.java | 23 +++++++++++++ .../spring/service/openAi/PromptBuilder.java | 12 +------ .../spring/service/pbti/PbtiServiceImpl.java | 32 +++++++++++++++++++ .../spring/web/dto/Pbti/PbtiResponseDTO.java | 5 +++ .../FastApiPbtiRecommendResponse.java | 26 +++++++++++++++ .../dto/external/FastApiRecommendRequest.java | 26 +++++++++++++++ 6 files changed, 113 insertions(+), 11 deletions(-) create mode 100644 src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiPbtiRecommendResponse.java diff --git a/src/main/java/PerfumeOnMe/spring/service/external/FastApiClient.java b/src/main/java/PerfumeOnMe/spring/service/external/FastApiClient.java index ced6620..d9709f0 100644 --- a/src/main/java/PerfumeOnMe/spring/service/external/FastApiClient.java +++ b/src/main/java/PerfumeOnMe/spring/service/external/FastApiClient.java @@ -9,6 +9,7 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; +import PerfumeOnMe.spring.web.dto.external.FastApiPbtiRecommendResponse; import PerfumeOnMe.spring.web.dto.external.FastApiRecommendRequest; import PerfumeOnMe.spring.web.dto.external.FastApiRecommendResponse; import lombok.RequiredArgsConstructor; @@ -22,6 +23,9 @@ public class FastApiClient { @Value("${external.fastapi.recommend-url}") private String fastApiRecommendUrl; + @Value("${external.fastapi.pbti-recommend-url}") + private String pbtiRecommendUrl; + public FastApiRecommendResponse getFullRecommendation(FastApiRecommendRequest request) { try { HttpHeaders headers = new HttpHeaders(); @@ -39,4 +43,23 @@ public FastApiRecommendResponse getFullRecommendation(FastApiRecommendRequest re return new FastApiRecommendResponse(); //임시조치 : FAST API 서버 없을 때 기본응답반환으로 서버 유지 } } + + public FastApiPbtiRecommendResponse getPbtiRecommendation(FastApiRecommendRequest.PbtiRequest request) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity entity = new HttpEntity<>(request, headers); + + ResponseEntity response = + restTemplate.exchange(pbtiRecommendUrl, HttpMethod.POST, entity, FastApiPbtiRecommendResponse.class); + + return response.getBody(); + + } catch (Exception e) { + // throw new GeneralException(ErrorStatus.FASTAPI_COMMUNICATION_ERROR); + return new FastApiPbtiRecommendResponse(); //임시조치 : FAST API 서버 없을 때 기본응답반환으로 서버 유지 + } + } + } diff --git a/src/main/java/PerfumeOnMe/spring/service/openAi/PromptBuilder.java b/src/main/java/PerfumeOnMe/spring/service/openAi/PromptBuilder.java index 27b037f..0ea895b 100644 --- a/src/main/java/PerfumeOnMe/spring/service/openAi/PromptBuilder.java +++ b/src/main/java/PerfumeOnMe/spring/service/openAi/PromptBuilder.java @@ -39,11 +39,9 @@ public static String buildPromptFromRequest(PbtiRequestDTO.PbtiQuestionRequest r - "scentPoint" 배열 내의 "category"와 "perfumeStyle" 내 "notes" 배열 내의 "category"는 한국어로 응답해주세요. - "scentPoint" 배열 내의 "point"는 숫자가 큰 순서대로 출력해주세요. - "summary"는 사용자의 성격이 반영되는 단어가 들어가도록 간단하게 요약해주세요. ex) “사람들과의 에너지 흐름을 잘 이끌어내는 ~한 사람” - - "perfumeRecommend" 배열 내의 "description"은 '이 향수는' 으로 시작되도록 하고 "name"과 "brand"는 실제 존재하는 향수와 브랜드이름으로하고 영어로 응답해줘. - "keywords" 배열에는 4개 항목을 포함하세요. - "perfumeStyle" 내 "notes" 배열에는 5개 항목을 포함하세요. - "scentPoint" 배열에는 5개 항목을 포함하세요. - - "perfumeRecommend" 배열에는 3개 항목을 포함하세요. { "recommendation": "...", "keywords": [ @@ -70,15 +68,7 @@ public static String buildPromptFromRequest(PbtiRequestDTO.PbtiQuestionRequest r } // 5개 항목 ], - "summary": "...", - "perfumeRecommend": [ - { - "name": "...", - "brand": "...", - "description": "..." - } - // 3개 항목 - ] + "summary": "..."x` } """, request.getQOne(), request.getQTwo(), request.getQThree(), request.getQFour(), request.getQFive(), request.getQSix(), request.getQSeven(), request.getQEight(), keywordString); diff --git a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java index 1327f98..b5c12b7 100644 --- a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java @@ -18,11 +18,14 @@ import PerfumeOnMe.spring.domain.User; import PerfumeOnMe.spring.repository.pbti.PbtiRepository; import PerfumeOnMe.spring.repository.user.UserRepository; +import PerfumeOnMe.spring.service.external.FastApiClient; import PerfumeOnMe.spring.service.openAi.OpenAiService; import PerfumeOnMe.spring.service.openAi.PromptBuilder; import PerfumeOnMe.spring.util.JsonUtils; import PerfumeOnMe.spring.web.dto.Pbti.PbtiRequestDTO; import PerfumeOnMe.spring.web.dto.Pbti.PbtiResponseDTO; +import PerfumeOnMe.spring.web.dto.external.FastApiPbtiRecommendResponse; +import PerfumeOnMe.spring.web.dto.external.FastApiRecommendRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -37,6 +40,7 @@ public class PbtiServiceImpl implements PbtiService { private final PbtiRepository pbtiRepository; private final UserRepository userRepository; private final StringRedisTemplate stringRedisTemplate; + private final FastApiClient fastApiClient; // PBTI 결과 조회 API @Override @@ -58,6 +62,34 @@ public PbtiResponseDTO.PbtiQuestionResponse searchPbti(Long userId, PbtiRequestD throw new GeneralException(ErrorStatus.JSON_PARSING_ERROR); } + // FastAPI 호출로 perfumeRecommend 대체 + FastApiRecommendRequest.PbtiRequest fastApiRequest = new FastApiRecommendRequest.PbtiRequest( + request.getQOne(), + request.getQTwo(), + request.getQThree(), + request.getQFour(), + request.getQFive(), + request.getQSix(), + request.getQSeven(), + request.getQEight() + ); + + FastApiPbtiRecommendResponse fastApiResponse = fastApiClient.getPbtiRecommendation(fastApiRequest); + + // ⬇ FastAPI 향수 추천 결과 매핑 + List mappedPerfumes = fastApiResponse.getPerfumeRecommend() + .stream() + .map(r -> PbtiResponseDTO.PbtiQuestionResponse.PerfumeRecommend.builder() + .name(r.getName()) + .brand(r.getBrand()) + .description(r.getDescription()) + .perfumeImageUrl(r.getPerfumeImageUrl()) + .build()) + .collect(Collectors.toList()); + + // 결과 세팅 + response.setPerfumeRecommend(mappedPerfumes); + // Redis에 저장할 DTO 생성 PbtiResponseDTO.PbtiRedisDTO redisDTO = PbtiResponseDTO.PbtiRedisDTO.builder() .qOne(request.getQOne()) diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java index 54e8491..5567d90 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java @@ -9,11 +9,13 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; public class PbtiResponseDTO { // PBTI 결과 조회 응답 DTO @Getter + @Setter @Builder @AllArgsConstructor @NoArgsConstructor @@ -35,6 +37,7 @@ public static class Keyword { } @Getter + @Setter @Builder @AllArgsConstructor @NoArgsConstructor @@ -69,6 +72,7 @@ public static class PerfumeRecommend { private String name; private String brand; private String description; + private String perfumeImageUrl; } } @@ -211,6 +215,7 @@ public static class PerfumeRecommend { private String name; private String brand; private String description; + private String perfumeImageUrl; } } diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiPbtiRecommendResponse.java b/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiPbtiRecommendResponse.java new file mode 100644 index 0000000..a5fb83f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiPbtiRecommendResponse.java @@ -0,0 +1,26 @@ +package PerfumeOnMe.spring.web.dto.external; + +import java.util.Collections; +import java.util.List; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class FastApiPbtiRecommendResponse { + private List perfumeRecommend = Collections.emptyList(); // 기본값 + + public FastApiPbtiRecommendResponse(List perfumeRecommend) { + this.perfumeRecommend = perfumeRecommend; + } + + @Getter + @NoArgsConstructor + public static class PbtiPerfumeRecommendation { + private String name; + private String brand; + private String description; + private String perfumeImageUrl; + } +} diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiRecommendRequest.java b/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiRecommendRequest.java index 0330d81..3f497c9 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiRecommendRequest.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiRecommendRequest.java @@ -1,5 +1,7 @@ package PerfumeOnMe.spring.web.dto.external; +import com.fasterxml.jackson.annotation.JsonProperty; + import lombok.AllArgsConstructor; import lombok.Getter; import lombok.NoArgsConstructor; @@ -15,4 +17,28 @@ public class FastApiRecommendRequest { private String gender; private String season; private String personality; + + // PBTI용 요청 DTO + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class PbtiRequest { + @JsonProperty("qOne") + private String qOne; + @JsonProperty("qTwo") + private String qTwo; + @JsonProperty("qThree") + private String qThree; + @JsonProperty("qFour") + private String qFour; + @JsonProperty("qFive") + private String qFive; + @JsonProperty("qSix") + private String qSix; + @JsonProperty("qSeven") + private String qSeven; + @JsonProperty("qEight") + private String qEight; + } } \ No newline at end of file From 8f2c5b26e38035acda0e6de51c4b08038c09ac57 Mon Sep 17 00:00:00 2001 From: bulee5328 Date: Sun, 27 Jul 2025 00:28:38 +0900 Subject: [PATCH 262/339] =?UTF-8?q?[Feature]=20PBTI=20=EA=B2=B0=EA=B3=BC?= =?UTF-8?q?=20=EC=9D=B4=EB=A6=84=20=EC=88=98=EC=A0=95,=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=EC=82=AD=EC=A0=9C=20API=20=EA=B5=AC=ED=98=84=20(#9?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/converter/PbtiConverter.java | 8 ++++ .../java/PerfumeOnMe/spring/domain/PBTI.java | 4 ++ .../spring/service/pbti/PbtiService.java | 7 ++++ .../spring/service/pbti/PbtiServiceImpl.java | 33 +++++++++++++++ .../spring/web/controller/PbtiController.java | 41 +++++++++++++++++++ .../spring/web/dto/Pbti/PbtiRequestDTO.java | 7 ++++ .../spring/web/dto/Pbti/PbtiResponseDTO.java | 10 +++++ 7 files changed, 110 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/converter/PbtiConverter.java b/src/main/java/PerfumeOnMe/spring/converter/PbtiConverter.java index faea45d..ec27e52 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/PbtiConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/PbtiConverter.java @@ -69,4 +69,12 @@ public static PbtiResponseDTO.PbtiResultDetailResponse toPbtiResultDetailRespons throw new GeneralException(ErrorStatus.JSON_PARSE_ERROR); } } + + // PBTI 결과 이름 수정 API + public static PbtiResponseDTO.UpdatePbtiNameResponse toUpdatePbtiNameResponse(PBTI pbti) { + return PbtiResponseDTO.UpdatePbtiNameResponse.builder() + .id(pbti.getId()) + .savedName(pbti.getSavedName()) + .build(); + } } \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/domain/PBTI.java b/src/main/java/PerfumeOnMe/spring/domain/PBTI.java index db2b654..01461d5 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/PBTI.java +++ b/src/main/java/PerfumeOnMe/spring/domain/PBTI.java @@ -90,4 +90,8 @@ public class PBTI extends BaseEntity { @OneToMany(mappedBy = "pbti", cascade = CascadeType.ALL) @Builder.Default private List RecommendedFragranceList = new ArrayList<>(); + + public void updateSavedName(String savedName) { + this.savedName = savedName; + } } diff --git a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiService.java b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiService.java index a51dec5..c3ecd2c 100644 --- a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiService.java +++ b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiService.java @@ -17,4 +17,11 @@ public interface PbtiService { // 마이페이지 PBTI 결과 상세 조회 API PbtiResponseDTO.PbtiResultDetailResponse searchPbtiResult(Long userId, PbtiRequestDTO.PbtiResultDetailRequest request); + + // PBTI 결과 이름 수정 API + PbtiResponseDTO.UpdatePbtiNameResponse updatePbtiName(Long userId, Long pbtiId, + PbtiRequestDTO.UpdatePbtiNameRequest request); + + // PBTI 결과 삭제 API + Void deletePbtiResult(Long userId, Long pbtiId); } diff --git a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java index b5c12b7..e1537b4 100644 --- a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java @@ -195,4 +195,37 @@ public PbtiResponseDTO.PbtiResultDetailResponse searchPbtiResult(Long userId, return PbtiConverter.toPbtiResultDetailResponse(pbti); } + // PBTI 결과 이름 수정 API + @Override + public PbtiResponseDTO.UpdatePbtiNameResponse updatePbtiName(Long userId, Long pbtiId, + PbtiRequestDTO.UpdatePbtiNameRequest request) { + + PBTI pbti = pbtiRepository.findById(pbtiId) + .orElseThrow(() -> new GeneralException(ErrorStatus.PBTI_NOT_EXIST_ERROR)); + + if (!pbti.getUser().getId().equals(userId)) { + throw new GeneralException(ErrorStatus.PBTI_USER_NOT_MATCH); + } + + pbti.updateSavedName(request.getSavedName()); + + return PbtiConverter.toUpdatePbtiNameResponse(pbti); + } + + // PBTI 결과 삭제 API + @Override + public Void deletePbtiResult(Long userId, Long pbtiId) { + + PBTI pbti = pbtiRepository.findById(pbtiId) + .orElseThrow(() -> new GeneralException(ErrorStatus.PBTI_NOT_EXIST_ERROR)); + + if (!pbti.getUser().getId().equals(userId)) { + throw new GeneralException(ErrorStatus.PBTI_USER_NOT_MATCH); + } + + pbtiRepository.delete(pbti); + + return null; + } + } diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/PbtiController.java b/src/main/java/PerfumeOnMe/spring/web/controller/PbtiController.java index 434641e..3c3f7ea 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/PbtiController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/PbtiController.java @@ -2,7 +2,10 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -97,4 +100,42 @@ public ResponseEntity> sea request); return ResponseEntity.ok(ApiResponse.onSuccess(result)); } + + // PBTI 결과 이름 수정 API + @PatchMapping("/{pbtiId}/name") + @Operation( + summary = "PBTI 결과 이름 수정", + description = "pbtiId에 해당하는 PBTI 결과 이름을 수정합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "PBTI 결과 이름이 수정되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = PbtiResponseDTO.UpdatePbtiNameResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PBTI4005", description = "존재하지 않는 PBTI 입니다.") + } + ) + public ResponseEntity> updatePbtiName( + @PathVariable Long pbtiId, + @RequestBody PbtiRequestDTO.UpdatePbtiNameRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + PbtiResponseDTO.UpdatePbtiNameResponse result = pbtiService.updatePbtiName(userDetails.getUserId(), + pbtiId, request); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } + + // PBTI 결과 삭제 API + @DeleteMapping("/{pbtiId}/result") + @Operation( + summary = "PBTI 결과 삭제", + description = "pbtiId에 해당하는 PBTI 결과를 삭제합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "PBTI 결과가 삭제되었습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PBTI4005", description = "존재하지 않는 PBTI 입니다.") + } + ) + public ResponseEntity> deletePbtiResult( + @PathVariable Long pbtiId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + Void result = pbtiService.deletePbtiResult(userDetails.getUserId(), pbtiId); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiRequestDTO.java index 2f5ab31..1042a71 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiRequestDTO.java @@ -55,4 +55,11 @@ public static class PbtiSaveRequest { public static class PbtiResultDetailRequest { private Long pbtiId; } + + // PBTI 결과 이름 수정 요청 DTO + @Getter + @Setter + public static class UpdatePbtiNameRequest { + private String savedName; + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java index 5567d90..1d22129 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java @@ -219,4 +219,14 @@ public static class PerfumeRecommend { } } + // PBTI 결과 이름 수정 응답 DTO + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class UpdatePbtiNameResponse { + private Long id; + private String savedName; + } + } From 6a96860485ecc0d7a57bee7f78622ac6171d6902 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Sun, 27 Jul 2025 00:38:22 +0900 Subject: [PATCH 263/339] =?UTF-8?q?[Fix]=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=BD=94=EB=93=9C=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/auth/service/LoginService.java | 6 ++++++ .../auth/service/LoginServiceImpl.java | 1 + .../security/oauth/service/KakaoService.java | 19 +++---------------- .../spring/service/user/UserServiceImpl.java | 4 +--- 4 files changed, 11 insertions(+), 19 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/service/LoginService.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/service/LoginService.java index f36eb37..1325abb 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/service/LoginService.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/service/LoginService.java @@ -2,12 +2,18 @@ import java.io.IOException; +import org.springframework.security.core.Authentication; + import PerfumeOnMe.spring.config.security.auth.dto.AuthRequestDTO; import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.domain.enums.Social; import jakarta.servlet.http.HttpServletResponse; public interface LoginService { public AuthResponseDTO.LoginResult login(AuthRequestDTO.Login request, HttpServletResponse response) throws IOException; + + AuthResponseDTO.LoginResult generateAuthResponse(String loginId, + Authentication request, Social social, HttpServletResponse response); } diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/service/LoginServiceImpl.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/service/LoginServiceImpl.java index 2b0f049..184775b 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/service/LoginServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/service/LoginServiceImpl.java @@ -65,6 +65,7 @@ public AuthResponseDTO.LoginResult login(AuthRequestDTO.Login requestDTO, HttpSe return generateAuthResponse(loginId, authResult, Social.LOCAL, response); } + @Override public AuthResponseDTO.LoginResult generateAuthResponse(String loginId, Authentication request, Social social, HttpServletResponse response) { diff --git a/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/KakaoService.java b/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/KakaoService.java index 476171f..2171f9e 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/KakaoService.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/KakaoService.java @@ -5,13 +5,12 @@ import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.stereotype.Service; -import PerfumeOnMe.spring.config.security.auth.converter.AuthConverter; import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; import PerfumeOnMe.spring.config.security.auth.manager.LogoutAccessTokenManager; import PerfumeOnMe.spring.config.security.auth.manager.RefreshTokenManager; import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; +import PerfumeOnMe.spring.config.security.auth.service.LoginService; import PerfumeOnMe.spring.config.security.auth.token.JwtAuthenticationToken; -import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; import PerfumeOnMe.spring.config.security.oauth.converter.OAuthConverter; import PerfumeOnMe.spring.config.security.oauth.dto.KakaoResponseDTO; import PerfumeOnMe.spring.config.security.oauth.util.KakaoClient; @@ -33,6 +32,7 @@ public class KakaoService implements OAuthService { private final UserDetailsService userDetailsService; private final RefreshTokenManager refreshTokenManager; private final LogoutAccessTokenManager logoutAccessTokenManager; + private final LoginService loginService; @Override public AuthResponseDTO.LoginResult oAuthLogin(String code, HttpServletResponse response) { @@ -61,26 +61,13 @@ public AuthResponseDTO.LoginResult oAuthLogin(String code, HttpServletResponse r // 사용자 JWT 인증 및 토큰 발급 UserDetails userDetails = userDetailsService.loadUserByUsername(user.getLoginId()); - Long userId = ((CustomUserDetails)userDetails).getUserId(); JwtAuthenticationToken request = new JwtAuthenticationToken( userDetails, null, userDetails.getAuthorities(), Social.KAKAO); - String accessToken = jwtTokenProvider.createAccessToken(request); - String refreshToken = jwtTokenProvider.createRefreshToken(request); - AuthResponseDTO.LoginResult loginResultDTO = AuthConverter.toLoginResult(refreshToken, userId, Social.KAKAO); - - // 새로 발급한 리프레시 토큰을 Redis에 저장 - refreshTokenManager.saveRefreshToken(email, refreshToken); - - // 액세스 토큰 헤더 설정 및 응답 DTO 반환 - response.setCharacterEncoding("UTF-8"); - response.setContentType("application/json"); - response.setStatus(HttpServletResponse.SC_OK); - response.setHeader("Authorization", "Bearer " + accessToken); // SecurityContextHolder에 인증 설정 SecurityContextHolder.getContext().setAuthentication(request); - return loginResultDTO; + return loginService.generateAuthResponse(email, request, Social.KAKAO, response); } @Override diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java index ba8ce3b..b5ef5e8 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java @@ -19,7 +19,6 @@ import PerfumeOnMe.spring.config.security.auth.manager.RefreshTokenManager; import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; import PerfumeOnMe.spring.config.security.auth.service.LoginService; -import PerfumeOnMe.spring.config.security.auth.service.LoginServiceImpl; import PerfumeOnMe.spring.config.security.auth.token.JwtAuthenticationToken; import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; import PerfumeOnMe.spring.converter.FragranceConverter; @@ -101,8 +100,7 @@ public AuthResponseDTO.LoginResult reissue(String reqRefreshToken, HttpServletRe Social social = ((CustomUserDetails)userDetails).getSocial(); JwtAuthenticationToken request = new JwtAuthenticationToken( userDetails, null, userDetails.getAuthorities(), social); - return ((LoginServiceImpl)loginService) - .generateAuthResponse(loginId, request, social, response); + return loginService.generateAuthResponse(loginId, request, social, response); } // 사용자 로그아웃 - 액세스 토큰과 리프레시 토큰 블랙리스트화 From 7bcdb1adc60ecacdda43a9987f3159c6b573d8d3 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 27 Jul 2025 18:11:47 +0900 Subject: [PATCH 264/339] =?UTF-8?q?[feature]=20=ED=96=A5=EC=88=98=EA=B3=B5?= =?UTF-8?q?=EB=B0=A9=20=EA=B2=B0=EA=B3=BC=20=EC=83=9D=EC=84=B1=20DTO,=20Co?= =?UTF-8?q?ntroller=20=EA=B5=AC=ED=98=84(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/controller/WorkshopController.java | 39 +++++++++++ .../web/dto/workshop/WorkshopRequestDTO.java | 64 +++++++++++++++++++ .../web/dto/workshop/WorkshopResponseDTO.java | 52 +++++++++++++-- 3 files changed, 150 insertions(+), 5 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java b/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java index 694bf95..2e85650 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java @@ -6,18 +6,22 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import PerfumeOnMe.spring.apiPayload.ApiResponse; import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; import PerfumeOnMe.spring.service.workshop.WorkshopService; +import PerfumeOnMe.spring.web.dto.workshop.WorkshopRequestDTO; import PerfumeOnMe.spring.web.dto.workshop.WorkshopResponseDTO; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RestController @@ -28,6 +32,41 @@ public class WorkshopController { private final WorkshopService workshopService; + /** 향수공방 결과 미리보기(결과 생성)*/ + @PostMapping("/preview") + @Operation( + summary = "향수공방 결과 확인(미리보기)", + description = "사용자가 선택한 향(Top, Middle, Base 노트)과 용량을 바탕으로 향기 해석 결과를 미리 확인합니다. " + + "결과는 Redis에 15분간 임시 저장되며, 향수공방 저장 API 호출 시 활용됩니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON200", + description = "요청에 성공하였습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = WorkshopResponseDTO.WorkshopPreviewResponseDTO.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON401", + description = "인증이 필요합니다. 액세스 토큰을 입력해주세요." + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "WORKSHOP4002", + description = "선택한 노트들의 총 용량은 10을 초과할 수 없습니다." + ) + } + ) + public ResponseEntity> getWorkshopPreview( + @Parameter(description = "향수공방 미리보기 생성 요청", required = true) + @RequestBody @Valid WorkshopRequestDTO.WorkshopPreviewRequestDTO request, + @Parameter(hidden = true) + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + return ResponseEntity.ok(ApiResponse.onSuccess(workshopService. + createWorkshopPreview(request, userDetails))); + } + /** 향수공방 목록 조회*/ @GetMapping("/result/list") @Operation( diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopRequestDTO.java index 5b65fe9..9150b93 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopRequestDTO.java @@ -1,4 +1,68 @@ package PerfumeOnMe.spring.web.dto.workshop; +import java.util.Map; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.Getter; + +/** 향수공방 요청 DTO 클라스*/ +@Schema(description = "향수공방 요청 DTO") public class WorkshopRequestDTO { + + /** 향수공방 결과 생성 요청 DTO*/ + @Builder + @Getter + @Schema(description = "향수공방 결과 생성 요청 DTO") + public static class WorkshopPreviewRequestDTO { + + @Schema(description = "탑 노트", example = "베르가못") + @NotNull(message = "탑 노트 값은 필수 입니다") + private String topNote; + + @Schema(description = "탑 노트 용량", example = "3") + @NotNull(message = "탑 노트 용량 값은 필수 입니다") + @Min(value = 0, message = "최소 탑 노트 용량은 0 이상 입니다.") + @Max(value = 10, message = "최대 탑 노트 용량은 10 이하 입니다.") + private Long topNoteVolume; + + @Schema(description = "미들 노트", example = "장미") + @NotNull(message = "미들노트 값은 필수 입니다") + private String middleNote; + + @Schema(description = "미들 노트 용량", example = "3") + @NotNull(message = "미들 노트 용량 값은 필수 입니다") + @Min(value = 0, message = "최소 미들 노트 용량은 0 이상 입니다.") + @Max(value = 10, message = "최대 미들 노트 용량은 10 이하 입니다.") + private Long middleNoteVolume; + + @Schema(description = "베이스 노트", example = "바닐라") + @NotNull(message = "베이스 노트 값은 필수 입니다") + private String baseNote; + + @Schema(description = "베이스 노트 용량", example = "4") + @NotNull(message = "베이스 노트 용량 값은 필수 입니다") + @Min(value = 0, message = "최소 베이스 노트 용량은 0 이상 입니다.") + @Max(value = 10, message = "최대 베이스 노트 용량은 10 이하 입니다.") + private Long baseNoteVolume; + } + + /** 향수 추천을 위한 요청 DTO (내부적으로 사용) */ + @Builder + @Getter + @Schema(description = "향수 추천을 위한 요청 DTO") + public static class WorkshopCreateRequestDTO { + + @Schema(description = "탑 노트 맵 (노트명: 용량)") + private Map topNoteList; + + @Schema(description = "미들 노트 맵 (노트명: 용량)") + private Map middleNoteList; + + @Schema(description = "베이스 노트 맵 (노트명: 용량)") + private Map baseNoteList; + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java index 14032b2..1df68b3 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java @@ -18,8 +18,14 @@ public class WorkshopResponseDTO { @Getter @Schema(description = "마이페이지 향수공방 목록 응답") public static class WorkshopListResponseDTO { + + @Schema(description = "향수공방 아이디", example = "1") private Long workshopId; + + @Schema(description = "저장된 향수공방 이름", example = "향수공방 해봤는데 맘에드는거1") private String savedName; + + @Schema(description = "생성날짜") private LocalDateTime createdAt; } @@ -30,19 +36,55 @@ public static class WorkshopListResponseDTO { @Schema(description = "향수공방 결과 상세조회") public static class WorkshopDetailResponseDTO { - @Schema(description = "키워드 요약", example = "#상큼한첫인상 #감정전달향 #무디한마무리") + @Schema(description = "시각적 키워드 (해시태그 형태)", example = "#상큼한첫인상 #감성적중심 #우디잔향\n#깊이있는사람 #신뢰감있는향기") + private String keywordSummary; + + @Schema(description = "향기의 첫인상 (탑 노트 설명 + 사용자 성향)", example = "베르가못의 상쾌한 시트러스 향이 첫 만남을 장식합니다. 이런 향을 선택하는 당신은 활기차고 긍정적인 에너지를 가진 사람으로 보입니다.") + private String firstImpression; + + @Schema(description = "중심을 잡는 향 (미들 노트 설명 + 사용자 성향)", example = "장미의 우아한 플로럴 향이 중심을 잡으며 로맨틱함을 연출합니다. 이런 향을 좋아하는 당신은 섬세하고 감성적인 면을 가진 사람입니다.") + private String centerImpression; + + @Schema(description = "마지막에 남는 잔향 (베이스 노트 설명 + 사용자 성향)", example = "샌달우드의 깊고 따뜻한 잔향이 오래도록 머물며 안정감을 줍니다. 이런 향을 선택하는 당신은 차분하고 신뢰할 수 있는 성격의 소유자입니다.") + private String lastImpression; + + @Schema(description = "향기로 해석한 당신의 성향 (전체 분석 + 기억되는 모습)", example = "복합적이고 다층적인 매력을 가진 당신은 첫인상은 밝고 활기차지만, 깊이 알수록 더욱 매력적인 면을 발견하게 됩니다. 당신은 사람들에게 '기분 좋은 여운이 오래 남는 사람'으로 기억됩니다.") + private String tendency; + + @Schema(description = "추천 향수 목록") + @JsonProperty("recommendedFragranceJson") + private List recommendedFragranceJson; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "향수공방 결과 미리보기 생성") + public static class WorkshopPreviewResponseDTO { + + @Schema(description = "탑 노트", example = "베르가못") + private String topNote; + + @Schema(description = "미들 노트", example = "장미") + private String middleNote; + + @Schema(description = "베이스 노트", example = "바닐라") + private String baseNote; + + @Schema(description = "시각적 키워드 (해시태그 형태)", example = "#상큼한첫인상 #감성적중심 #우디잔향\n#깊이있는사람 #신뢰감있는향기") private String keywordSummary; - @Schema(description = "첫인상 설명", example = "상큼한 시트러스가 먼저 퍼지며, 활기차고 개방적인 에너지를 전달합니다...") + @Schema(description = "향기의 첫인상 (탑 노트 설명 + 사용자 성향)", example = "베르가못의 상쾌한 시트러스 향이 첫 만남을 장식합니다. 이런 향을 선택하는 당신은 활기차고 긍정적인 에너지를 가진 사람으로 보입니다.") private String firstImpression; - @Schema(description = "중간 인상 설명", example = "곧이어 재스민의 은은한 꽃향기가 중심을 잡습니다...") + @Schema(description = "중심을 잡는 향 (미들 노트 설명 + 사용자 성향)", example = "장미의 우아한 플로럴 향이 중심을 잡으며 로맨틱함을 연출합니다. 이런 향을 좋아하는 당신은 섬세하고 감성적인 면을 가진 사람입니다.") private String centerImpression; - @Schema(description = "마지막 인상 설명", example = "이 향기의 핵심은 단연 우디 노트입니다...") + @Schema(description = "마지막에 남는 잔향 (베이스 노트 설명 + 사용자 성향)", example = "샌달우드의 깊고 따뜻한 잔향이 오래도록 머물며 안정감을 줍니다. 이런 향을 선택하는 당신은 차분하고 신뢰할 수 있는 성격의 소유자입니다.") private String lastImpression; - @Schema(description = "성향 분석", example = "🌞 겉으로는 밝고 유쾌하며 누구든 쉽게 다가갈 수 있는 사람...") + @Schema(description = "향기로 해석한 당신의 성향 (전체 분석 + 기억되는 모습)", example = "복합적이고 다층적인 매력을 가진 당신은 첫인상은 밝고 활기차지만, 깊이 알수록 더욱 매력적인 면을 발견하게 됩니다. 당신은 사람들에게 '기분 좋은 여운이 오래 남는 사람'으로 기억됩니다.") private String tendency; @Schema(description = "추천 향수 목록") From 9c85db1af099a0d64a173f821c4eb2fa25bf7a5c Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 27 Jul 2025 18:14:20 +0900 Subject: [PATCH 265/339] =?UTF-8?q?[feature]=20=ED=96=A5=EC=88=98=EA=B3=B5?= =?UTF-8?q?=EB=B0=A9=20=EA=B2=B0=EA=B3=BC=20=EC=83=9D=EC=84=B1=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EA=B5=AC=ED=98=84(=EC=B6=94=EC=B2=9C,=20o?= =?UTF-8?q?penai=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=ED=8F=AC=ED=95=A8)=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/redis/WorkshopRedisService.java | 79 +++++++ .../WorkshopRecommendationService.java | 221 ++++++++++++++++++ .../service/workshop/WorkshopResult.java | 22 ++ .../workshop/WorkshopResultParser.java | 85 +++++++ .../service/workshop/WorkshopService.java | 88 +++++++ src/main/resources/prompts/workshop.txt | 45 ++++ 6 files changed, 540 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/service/redis/WorkshopRedisService.java create mode 100644 src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopRecommendationService.java create mode 100644 src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopResult.java create mode 100644 src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopResultParser.java create mode 100644 src/main/resources/prompts/workshop.txt diff --git a/src/main/java/PerfumeOnMe/spring/service/redis/WorkshopRedisService.java b/src/main/java/PerfumeOnMe/spring/service/redis/WorkshopRedisService.java new file mode 100644 index 0000000..ccf11a3 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/redis/WorkshopRedisService.java @@ -0,0 +1,79 @@ +package PerfumeOnMe.spring.service.redis; + +import java.time.Duration; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.web.dto.workshop.WorkshopResponseDTO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** + * 향수공방 미리보기 결과를 Redis에 임시 저장하는 서비스 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WorkshopRedisService { + + private static final Duration TTL = Duration.ofMinutes(15); // 15분 TTL + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + /** + * 향수공방 미리보기 결과를 Redis에 저장 + */ + public void savePreview(Long userId, WorkshopResponseDTO.WorkshopPreviewResponseDTO dto) { + try { + String key = buildKey(userId); + String json = objectMapper.writeValueAsString(dto); + redisTemplate.opsForValue().set(key, json, TTL); + log.info("향수공방 미리보기 결과 Redis 저장 완료 - 사용자 ID: {}, 키: {}", userId, key); + } catch (Exception e) { + log.error("향수공방 미리보기 결과 Redis 저장 실패 - 사용자 ID: {}, 오류: {}", userId, e.getMessage(), e); + throw new GeneralException(ErrorStatus.JSON_PARSE_ERROR); + } + } + + /** + * Redis에서 향수공방 미리보기 결과를 조회 + */ + public WorkshopResponseDTO.WorkshopPreviewResponseDTO getPreview(Long userId) { + try { + String key = buildKey(userId); + String json = redisTemplate.opsForValue().get(key); + if (json == null) { + log.warn("향수공방 미리보기 결과가 만료되었거나 존재하지 않음 - 사용자 ID: {}", userId); + throw new GeneralException(ErrorStatus.EXPIRED_WORKSHOP_RESULT); + } + log.info("향수공방 미리보기 결과 Redis 조회 완료 - 사용자 ID: {}", userId); + return objectMapper.readValue(json, WorkshopResponseDTO.WorkshopPreviewResponseDTO.class); + } catch (GeneralException e) { + throw e; + } catch (Exception e) { + log.error("향수공방 미리보기 결과 Redis 조회 실패 - 사용자 ID: {}, 오류: {}", userId, e.getMessage(), e); + throw new GeneralException(ErrorStatus.JSON_PARSE_ERROR); + } + } + + /** + * Redis에서 향수공방 미리보기 결과를 삭제 + */ + public void deletePreview(Long userId) { + String key = buildKey(userId); + redisTemplate.delete(key); + log.info("향수공방 미리보기 결과 Redis 삭제 완료 - 사용자 ID: {}", userId); + } + + /** + * Redis 키 생성 + */ + private String buildKey(Long userId) { + return "workshop:preview:" + userId; + } +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopRecommendationService.java b/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopRecommendationService.java new file mode 100644 index 0000000..638ebcd --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopRecommendationService.java @@ -0,0 +1,221 @@ +package PerfumeOnMe.spring.service.workshop; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; + +import PerfumeOnMe.spring.domain.WorkshopFragrance; +import PerfumeOnMe.spring.repository.WorkshopFragranceRepository; +import PerfumeOnMe.spring.web.dto.workshop.WorkshopRequestDTO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class WorkshopRecommendationService { + + private final WorkshopFragranceRepository workshopFragranceRepository; + + // 점수 가중치 상수 + private static final double NOTE_WEIGHT = 0.4; // 노트 매칭 40% + private static final double ACCORD_WEIGHT = 0.6; // 메인어코드 매칭 60% + + // 노트 매칭 점수 + private static final double PERFECT_NOTE_MATCH = 100.0; // 정확한 위치 매치 + private static final double DIFFERENT_POSITION_MATCH = 50.0; // 다른 위치 매치 + private static final double NO_MATCH_PENALTY = -10.0; // 매치 없음 페널티 + + // 메인어코드 매칭 점수 + private static final double FIRST_ACCORD_MATCH = 100.0; // 1순위 매치 + private static final double SECOND_ACCORD_MATCH = 60.0; // 2순위 매치 + private static final double THIRD_ACCORD_MATCH = 30.0; // 3순위 매치 + + /** + * 사용자의 향수공방 선택을 기반으로 상위 3개 향수 추천 + */ + public List recommendFragrances(WorkshopRequestDTO.WorkshopCreateRequestDTO request) { + log.info("향수 추천 시작 - 사용자 노트 선택: Top={}, Middle={}, Base={}", + request.getTopNoteList(), request.getMiddleNoteList(), request.getBaseNoteList()); + + // 모든 향수 조회 + List allFragrances = workshopFragranceRepository.findAllForRecommendation(); + log.info("전체 향수 개수: {}", allFragrances.size()); + + // 각 향수에 대해 점수 계산 + List fragranceScores = allFragrances.stream() + .map(fragrance -> calculateScore(fragrance, request)) + .collect(Collectors.toList()); + + // 점수 기준으로 정렬하고 상위 3개 선택 + List recommendations = fragranceScores.stream() + .sorted(Comparator.comparingDouble(FragranceScore::getScore).reversed()) + .limit(3) + .map(FragranceScore::getFragrance) + .collect(Collectors.toList()); + + log.info("추천 완료 - 상위 3개 향수 선택됨"); + return recommendations; + } + + /** + * 향수에 대한 종합 점수 계산 + */ + private FragranceScore calculateScore(WorkshopFragrance fragrance, WorkshopRequestDTO.WorkshopCreateRequestDTO request) { + double noteScore = calculateNoteMatchingScore(fragrance, request); + double accordScore = calculateAccordMatchingScore(fragrance, request); + + double totalScore = (noteScore * NOTE_WEIGHT) + (accordScore * ACCORD_WEIGHT); + + return new FragranceScore(fragrance, totalScore); + } + + /** + * 노트 매칭 점수 계산 (40% 가중치) + */ + private double calculateNoteMatchingScore(WorkshopFragrance fragrance, WorkshopRequestDTO.WorkshopCreateRequestDTO request) { + double totalScore = 0.0; + int totalWeight = 0; + + // 사용자 선택 노트와 용량 정보 + Map userTopNotes = request.getTopNoteList(); + Map userMiddleNotes = request.getMiddleNoteList(); + Map userBaseNotes = request.getBaseNoteList(); + + // Top 노트 매칭 + for (Map.Entry entry : userTopNotes.entrySet()) { + String noteName = entry.getKey(); + int volume = entry.getValue(); + + double score = calculateSingleNoteScore(fragrance, noteName, "top"); + totalScore += score * volume; + totalWeight += volume; + } + + // Middle 노트 매칭 + for (Map.Entry entry : userMiddleNotes.entrySet()) { + String noteName = entry.getKey(); + int volume = entry.getValue(); + + double score = calculateSingleNoteScore(fragrance, noteName, "middle"); + totalScore += score * volume; + totalWeight += volume; + } + + // Base 노트 매칭 + for (Map.Entry entry : userBaseNotes.entrySet()) { + String noteName = entry.getKey(); + int volume = entry.getValue(); + + double score = calculateSingleNoteScore(fragrance, noteName, "base"); + totalScore += score * volume; + totalWeight += volume; + } + + return totalWeight > 0 ? totalScore / totalWeight : 0.0; + } + + /** + * 개별 노트 점수 계산 + */ + private double calculateSingleNoteScore(WorkshopFragrance fragrance, String noteName, String expectedPosition) { + String topNote = fragrance.getTopNote() != null ? fragrance.getTopNote() : ""; + String middleNote = fragrance.getMiddleNote() != null ? fragrance.getMiddleNote() : ""; + String baseNote = fragrance.getBaseNote() != null ? fragrance.getBaseNote() : ""; + + // 정확한 위치에서 매치 + switch (expectedPosition) { + case "top": + if (topNote.contains(noteName)) return PERFECT_NOTE_MATCH; + break; + case "middle": + if (middleNote.contains(noteName)) return PERFECT_NOTE_MATCH; + break; + case "base": + if (baseNote.contains(noteName)) return PERFECT_NOTE_MATCH; + break; + } + + // 다른 위치에서 매치 + if (topNote.contains(noteName) || middleNote.contains(noteName) || baseNote.contains(noteName)) { + return DIFFERENT_POSITION_MATCH; + } + + // 매치 없음 + return NO_MATCH_PENALTY; + } + + /** + * 메인어코드 매칭 점수 계산 (60% 가중치) + */ + private double calculateAccordMatchingScore(WorkshopFragrance fragrance, WorkshopRequestDTO.WorkshopCreateRequestDTO request) { + // 사용자가 선택한 모든 노트 수집 + List userSelectedNotes = new ArrayList<>(); + userSelectedNotes.addAll(request.getTopNoteList().keySet()); + userSelectedNotes.addAll(request.getMiddleNoteList().keySet()); + userSelectedNotes.addAll(request.getBaseNoteList().keySet()); + + double totalScore = 0.0; + int matchCount = 0; + + // 향수의 메인어코드들 + List fragranceAccords = Arrays.asList( + fragrance.getMainAccord1(), + fragrance.getMainAccord2(), + fragrance.getMainAccord3() + ); + + // 각 메인어코드에 대해 점수 계산 + for (int i = 0; i < fragranceAccords.size(); i++) { + String accord = fragranceAccords.get(i); + if (accord == null || accord.trim().isEmpty()) continue; + + // 사용자 선택 노트와 매칭 확인 + boolean matched = userSelectedNotes.stream() + .anyMatch(note -> accord.contains(note) || note.contains(accord)); + + if (matched) { + switch (i) { + case 0: // 1순위 + totalScore += FIRST_ACCORD_MATCH; + break; + case 1: // 2순위 + totalScore += SECOND_ACCORD_MATCH; + break; + case 2: // 3순위 + totalScore += THIRD_ACCORD_MATCH; + break; + } + matchCount++; + } + } + + return matchCount > 0 ? totalScore / matchCount : 0.0; + } + + /** + * 향수와 점수를 함께 저장하는 내부 클래스 + */ + private static class FragranceScore { + private final WorkshopFragrance fragrance; + private final double score; + + public FragranceScore(WorkshopFragrance fragrance, double score) { + this.fragrance = fragrance; + this.score = score; + } + + public WorkshopFragrance getFragrance() { + return fragrance; + } + + public double getScore() { + return score; + } + } +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopResult.java b/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopResult.java new file mode 100644 index 0000000..abc65de --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopResult.java @@ -0,0 +1,22 @@ +package PerfumeOnMe.spring.service.workshop; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * GPT 응답으로부터 파싱된 향수공방 결과를 담는 클래스 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WorkshopResult { + + private String keywordSummary; // 시각적 키워드 (해시태그 형태) + private String firstImpression; // 향기의 첫인상 (탑 노트 설명 + 사용자 성향) + private String centerImpression; // 중심을 잡는 향 (미들 노트 설명 + 사용자 성향) + private String lastImpression; // 마지막에 남는 잔향 (베이스 노트 설명 + 사용자 성향) + private String tendency; // 향기로 해석한 당신의 성향 (전체 분석 + 기억되는 모습) +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopResultParser.java b/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopResultParser.java new file mode 100644 index 0000000..fbcf424 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopResultParser.java @@ -0,0 +1,85 @@ +package PerfumeOnMe.spring.service.workshop; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import lombok.extern.slf4j.Slf4j; + +/** + * GPT 응답을 파싱하여 WorkshopResult 객체로 변환하는 파서 클래스 + */ +@Slf4j +public class WorkshopResultParser { + + /** + * GPT 응답 텍스트를 파싱하여 WorkshopResult 객체로 변환 + * + * 예상 GPT 응답 형식: + * 시각적 키워드: #상큼한첫인상 #감성적중심 #우디잔향 + * #깊이있는사람 #신뢰감있는향기 + * 향기의 첫인상: 베르가못의 상쾌한 시트러스 향이 첫 만남을 장식합니다. 이런 향을 선택하는 당신은 활기차고 긍정적인 에너지를 가진 사람으로 보입니다. + * 중심을 잡는 향: 장미의 우아한 플로럴 향이 중심을 잡으며 로맨틱함을 연출합니다. 이런 향을 좋아하는 당신은 섬세하고 감성적인 면을 가진 사람입니다. + * 마지막에 남는 잔향: 샌달우드의 깊고 따뜻한 잔향이 오래도록 머물며 안정감을 줍니다. 이런 향을 선택하는 당신은 차분하고 신뢰할 수 있는 성격의 소유자입니다. + * 향기로 해석한 당신의 성향: 복합적이고 다층적인 매력을 가진 당신은 첫인상은 밝고 활기차지만, 깊이 알수록 더욱 매력적인 면을 발견하게 됩니다. 당신은 사람들에게 '기분 좋은 여운이 오래 남는 사람'으로 기억됩니다. + */ + public WorkshopResult parseGptResponse(String gptResponse) { + try { + log.info("GPT 응답 파싱 시작"); + + String keywordSummary = extractValue(gptResponse, "시각적 키워드:"); + String firstImpression = extractValue(gptResponse, "향기의 첫인상:"); + String centerImpression = extractValue(gptResponse, "중심을 잡는 향:"); + String lastImpression = extractValue(gptResponse, "마지막에 남는 잔향:"); + String tendency = extractValue(gptResponse, "향기로 해석한 당신의 성향:"); + + WorkshopResult result = WorkshopResult.builder() + .keywordSummary(keywordSummary) + .firstImpression(firstImpression) + .centerImpression(centerImpression) + .lastImpression(lastImpression) + .tendency(tendency) + .build(); + + log.info("GPT 응답 파싱 완료 - 시각적 키워드: {}", keywordSummary); + return result; + + } catch (Exception e) { + log.error("GPT 응답 파싱 중 오류 발생: {}", e.getMessage(), e); + log.error("원본 GPT 응답: {}", gptResponse); + + // 파싱 실패 시 기본값 반환 + return WorkshopResult.builder() + .keywordSummary("#맞춤형향수 #개성있는조합 #특별한향기\n#매력적인사람 #기억에남는향") + .firstImpression("선택한 탑 노트가 상쾌한 첫인상을 선사합니다. 당신은 활기찬 에너지를 가진 사람으로 보입니다.") + .centerImpression("미들 노트가 조화로운 중심을 잡아줍니다. 당신은 균형감 있는 성격의 소유자입니다.") + .lastImpression("베이스 노트가 깊이 있는 잔향을 남깁니다. 당신은 신뢰할 수 있는 매력을 가진 사람입니다.") + .tendency("개성 있는 향을 추구하는 당신은 자신만의 스타일을 가진 사람입니다. 당신은 사람들에게 '특별한 매력을 가진 사람'으로 기억됩니다.") + .build(); + } + } + + /** + * 특정 키워드 뒤의 값을 추출하는 헬퍼 메서드 + */ + private String extractValue(String text, String keyword) { + try { + // 키워드 뒤에 오는 내용을 다음 키워드나 문서 끝까지 추출 + String regex = keyword + "\\s*([^\\n]*(?:\\n(?!시각적 키워드:|향기의 첫인상:|중심을 잡는 향:|마지막에 남는 잔향:|향기로 해석한 당신의 성향:)[^\\n]*)*)"; + Pattern pattern = Pattern.compile(regex, Pattern.MULTILINE | Pattern.DOTALL); + Matcher matcher = pattern.matcher(text); + + if (matcher.find()) { + String value = matcher.group(1).trim(); + log.debug("추출된 값 - {}: {}", keyword, value); + return value; + } + + log.warn("키워드 '{}'에 대한 값을 찾을 수 없습니다.", keyword); + return "정보를 찾을 수 없습니다."; + + } catch (Exception e) { + log.error("값 추출 중 오류 발생 - 키워드: {}, 오류: {}", keyword, e.getMessage()); + return "정보를 찾을 수 없습니다."; + } + } +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java b/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java index d3f79f6..2b07c87 100644 --- a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java +++ b/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java @@ -1,6 +1,8 @@ package PerfumeOnMe.spring.service.workshop; +import java.util.HashMap; import java.util.List; +import java.util.Map; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -11,18 +13,27 @@ import PerfumeOnMe.spring.converter.WorkshopConverter; import PerfumeOnMe.spring.domain.User; import PerfumeOnMe.spring.domain.Workshop; +import PerfumeOnMe.spring.domain.WorkshopFragrance; import PerfumeOnMe.spring.repository.user.UserRepository; import PerfumeOnMe.spring.repository.workshop.WorkshopRepository; +import PerfumeOnMe.spring.service.openAi.OpenAiService; +import PerfumeOnMe.spring.service.redis.WorkshopRedisService; import PerfumeOnMe.spring.service.user.UserService; +import PerfumeOnMe.spring.web.dto.workshop.WorkshopRequestDTO; import PerfumeOnMe.spring.web.dto.workshop.WorkshopResponseDTO; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @RequiredArgsConstructor public class WorkshopService { private final WorkshopRepository workshopRepository; private final UserRepository userRepository; private final UserService userService; + private final OpenAiService openAiService; + private final WorkshopRedisService workshopRedisService; + private final WorkshopRecommendationService workshopRecommendationService; @Transactional(readOnly = true) public List findAllWorkshopsByUser(CustomUserDetails userDetails) { @@ -70,4 +81,81 @@ public WorkshopResponseDTO.WorkshopDetailResponseDTO findWorkshopById( return WorkshopConverter.toWorkshopDetailResponse(workshop); } + @Transactional + public WorkshopResponseDTO.WorkshopPreviewResponseDTO createWorkshopPreview( + WorkshopRequestDTO.WorkshopPreviewRequestDTO request, CustomUserDetails userDetails + ) { + Long userId = userDetails.getUserId(); + + // 유저 ID NULL 검증 + if (userId == null) { + throw new GeneralException(ErrorStatus.USER_ID_NULL); + } + + // 유저 존재 여부 검증 + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + + // 용량 검증 (3개의 총 합이 10 이하인지를 검증) + Long totalNoteVolume = request.getTopNoteVolume() + request.getMiddleNoteVolume() + request.getBaseNoteVolume(); + if (totalNoteVolume > 10) { + throw new GeneralException(ErrorStatus.WORKSHOP_TOTAL_VOLUME_OVERFLOW); + } + + // 서비스 동작 + log.info("향수공방 미리보기 생성 시작 - 사용자 ID: {}", userId); + + // GPT API를 통한 향수공방 결과 생성 + String gptResult = openAiService.generateWorkshopResult( + request.getTopNote(), request.getTopNoteVolume(), + request.getMiddleNote(), request.getMiddleNoteVolume(), + request.getBaseNote(), request.getBaseNoteVolume() + ); + + // GPT 응답 파싱 + WorkshopResultParser parser = new WorkshopResultParser(); + WorkshopResult workshopResult = parser.parseGptResponse(gptResult); + + log.info("향수공방 미리보기 생성 완료 - 사용자 ID: {}, 키워드: {}", userId, workshopResult.getKeywordSummary()); + + // 향수 추천 생성 (노트 정보를 추천 서비스용 형태로 변환) + WorkshopRequestDTO.WorkshopCreateRequestDTO recommendRequest = + convertToRecommendRequest(request); + List recommendedFragrances = + workshopRecommendationService.recommendFragrances(recommendRequest); + + log.info("향수 추천 완료 - 추천된 향수 개수: {}", recommendedFragrances.size()); + + // 응답 DTO 생성 (추천 향수 포함) + WorkshopResponseDTO.WorkshopPreviewResponseDTO response = + WorkshopConverter.toWorkshopPreviewResponse(request, workshopResult, recommendedFragrances); + + // Redis에 미리보기 결과 저장 (15분 TTL) + workshopRedisService.savePreview(userId, response); + + return response; + } + + /** + * WorkshopPreviewRequestDTO를 WorkshopCreateRequestDTO로 변환 + */ + private WorkshopRequestDTO.WorkshopCreateRequestDTO convertToRecommendRequest( + WorkshopRequestDTO.WorkshopPreviewRequestDTO request) { + + Map topNoteMap = new HashMap<>(); + topNoteMap.put(request.getTopNote(), request.getTopNoteVolume().intValue()); + + Map middleNoteMap = new HashMap<>(); + middleNoteMap.put(request.getMiddleNote(), request.getMiddleNoteVolume().intValue()); + + Map baseNoteMap = new HashMap<>(); + baseNoteMap.put(request.getBaseNote(), request.getBaseNoteVolume().intValue()); + + return WorkshopRequestDTO.WorkshopCreateRequestDTO.builder() + .topNoteList(topNoteMap) + .middleNoteList(middleNoteMap) + .baseNoteList(baseNoteMap) + .build(); + } + } diff --git a/src/main/resources/prompts/workshop.txt b/src/main/resources/prompts/workshop.txt new file mode 100644 index 0000000..27ab0c2 --- /dev/null +++ b/src/main/resources/prompts/workshop.txt @@ -0,0 +1,45 @@ +⚙️ 시스템 메시지 + +너는 향수 전문가야. 사용자가 선택한 탑/미들/베이스 노트와 각각의 용량을 바탕으로 개인 맞춤형 향수 분석 결과를 생성해줘. + +사용자 입력 정보: +- 탑 노트: {topNoteType} (용량: {topNoteVolume}) +- 미들 노트: {middleNoteType} (용량: {middleNoteVolume}) +- 베이스 노트: {baseNoteType} (용량: {baseNoteVolume}) + +다음 5가지 항목에 대해 분석해줘: + +1. **시각적 키워드 (visualKeywords)**: 3개 노트가 합쳐졌을 때의 느낌을 해시태그 형태로 표현 + - 해시태그 하나당 10글자 이내 + - 총 5개의 해시태그를 2줄로 배치 (첫 줄 3개, 둘째 줄 2개) + 예: "#상큼한첫인상 #감성적중심 #우디잔향\n#깊이있는사람 #신뢰감있는향기" + +2. **향기의 첫인상 (firstImpression)**: 탑 노트({topNoteType})에 대한 설명과 이를 선택한 사용자의 성향 (2-3문장) + 예: "베르가못의 상쾌한 시트러스 향이 첫 만남을 장식합니다. 이런 향을 선택하는 당신은 활기차고 긍정적인 에너지를 가진 사람으로 보입니다." + +3. **중심을 잡는 향 (centerImpression)**: 미들 노트({middleNoteType})에 대한 설명과 이를 선택한 사용자의 성향 (2-3문장) + 예: "장미의 우아한 플로럴 향이 중심을 잡으며 로맨틱함을 연출합니다. 이런 향을 좋아하는 당신은 섬세하고 감성적인 면을 가진 사람입니다." + +4. **마지막에 남는 잔향 (lastImpression)**: 베이스 노트({baseNoteType})에 대한 설명과 이를 선택한 사용자의 성향 (2-3문장) + 예: "샌달우드의 깊고 따뜻한 잔향이 오래도록 머물며 안정감을 줍니다. 이런 향을 선택하는 당신은 차분하고 신뢰할 수 있는 성격의 소유자입니다." + +5. **향기로 해석한 당신의 성향 (tendency)**: 3개 노트가 비율을 반영해서 합쳐진 향기를 선택한 사용자의 전체적인 성향 분석 + - 사용자가 어떤 사람일지 추측/해석 (2-3문장) + - 마지막 문장은 반드시: "당신은 사람들에게 '[특성]한 사람'으로 기억됩니다." + 예: "복합적이고 다층적인 매력을 가진 당신은 첫인상은 밝고 활기차지만, 깊이 알수록 더욱 매력적인 면을 발견하게 됩니다. 당신은 사람들에게 '기분 좋은 여운이 오래 남는 사람'으로 기억됩니다." + +출력 형식 (이 형식을 절대 벗어나지 마): + +시각적 키워드: {해시태그 5개를 2줄로} +향기의 첫인상: {탑 노트 설명 + 사용자 성향} +중심을 잡는 향: {미들 노트 설명 + 사용자 성향} +마지막에 남는 잔향: {베이스 노트 설명 + 사용자 성향} +향기로 해석한 당신의 성향: {전체 성향 분석 + 마지막 기억되는 모습} + +주의 사항: +1. **반드시 5개 항목 모두 생성할 것** +2. **지정된 출력 형식만 사용하고, 그 외 설명은 절대 하지 마** +3. **각 노트의 용량 비율을 고려하여 분석할 것** +4. **친근하고 감성적인 톤으로 작성** +5. **해시태그는 정확히 # 기호로 시작하고 10글자 이내로 작성** +6. **마지막 성향 분석의 끝 문장은 반드시 "당신은 사람들에게 '[특성]한 사람'으로 기억됩니다." 형식 준수** \ No newline at end of file From a2be8c9f855f5117a6bd933aaaf3e0a2220a5ac0 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 27 Jul 2025 18:14:49 +0900 Subject: [PATCH 266/339] =?UTF-8?q?[feature]=20=ED=96=A5=EC=88=98=EA=B3=B5?= =?UTF-8?q?=EB=B0=A9=20=EA=B2=B0=EA=B3=BC=20=EC=83=9D=EC=84=B1=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/domain/WorkshopFragrance.java | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/domain/WorkshopFragrance.java diff --git a/src/main/java/PerfumeOnMe/spring/domain/WorkshopFragrance.java b/src/main/java/PerfumeOnMe/spring/domain/WorkshopFragrance.java new file mode 100644 index 0000000..f6725f5 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/domain/WorkshopFragrance.java @@ -0,0 +1,65 @@ +package PerfumeOnMe.spring.domain; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +import PerfumeOnMe.spring.domain.base.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@DynamicInsert +@DynamicUpdate +@Table(name = "workshop_fragrances") +public class WorkshopFragrance extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 100) + private String name; // 향수이름 + + @Column(nullable = false, length = 50) + private String brand; // 브랜드 + + @Column(length = 50) + private String mainAccord1; // 메인어코드 1순위 + + @Column(length = 50) + private String mainAccord2; // 메인어코드 2순위 + + @Column(length = 50) + private String mainAccord3; // 메인어코드 3순위 + + @Column(columnDefinition = "TEXT") + private String topNote; // 탑노트 + + @Column(columnDefinition = "TEXT") + private String middleNote; // 미들노트 + + @Column(columnDefinition = "TEXT") + private String baseNote; // 베이스노트 + + @Column(length = 500) + private String imageUrl; // 향수이미지 + + @Column(columnDefinition = "TEXT") + private String description; // 향수설명 + + @Column(nullable = false) + private Integer price; // 가격 +} \ No newline at end of file From 2df6e47d0ae10628752f965baded971d19acb81b Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 27 Jul 2025 18:15:22 +0900 Subject: [PATCH 267/339] =?UTF-8?q?[feature]=20=ED=96=A5=EC=88=98=EA=B3=B5?= =?UTF-8?q?=EB=B0=A9=20=EA=B2=B0=EA=B3=BC=20=EC=83=9D=EC=84=B1=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20Converter(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/converter/WorkshopConverter.java | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java b/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java index 7cc1287..29a78ba 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java @@ -2,12 +2,16 @@ import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import PerfumeOnMe.spring.domain.Workshop; +import PerfumeOnMe.spring.domain.WorkshopFragrance; +import PerfumeOnMe.spring.service.workshop.WorkshopResult; +import PerfumeOnMe.spring.web.dto.workshop.WorkshopRequestDTO; import PerfumeOnMe.spring.web.dto.workshop.WorkshopResponseDTO; public class WorkshopConverter { @@ -40,6 +44,58 @@ public static WorkshopResponseDTO.WorkshopDetailResponseDTO toWorkshopDetailResp .build(); } + /** 향수공방 미리보기 응답 DTO 생성 */ + public static WorkshopResponseDTO.WorkshopPreviewResponseDTO toWorkshopPreviewResponse( + WorkshopRequestDTO.WorkshopPreviewRequestDTO request, + WorkshopResult workshopResult + ) { + // TODO: 향수 추천 로직은 FastAPI 연동 후 구현 예정 + List emptyRecommendations = new ArrayList<>(); + + return WorkshopResponseDTO.WorkshopPreviewResponseDTO.builder() + .topNote(request.getTopNote()) + .middleNote(request.getMiddleNote()) + .baseNote(request.getBaseNote()) + .keywordSummary(workshopResult.getKeywordSummary()) + .firstImpression(workshopResult.getFirstImpression()) + .centerImpression(workshopResult.getCenterImpression()) + .lastImpression(workshopResult.getLastImpression()) + .tendency(workshopResult.getTendency()) + .recommendedFragranceJson(emptyRecommendations) + .build(); + } + + /** 향수공방 미리보기 응답 DTO 생성 (추천 향수 포함) */ + public static WorkshopResponseDTO.WorkshopPreviewResponseDTO toWorkshopPreviewResponse( + WorkshopRequestDTO.WorkshopPreviewRequestDTO request, + WorkshopResult workshopResult, + List recommendedFragrances + ) { + // WorkshopFragrance를 RecommendedFragranceDTO로 변환 + List recommendedFragranceDTOList = + recommendedFragrances.stream() + .map(fragrance -> WorkshopResponseDTO.RecommendedFragranceDTO.builder() + .brand(fragrance.getBrand()) + .name(fragrance.getName()) + .description(fragrance.getDescription()) + .price(fragrance.getPrice()) + .imageUrl(fragrance.getImageUrl()) + .build()) + .collect(Collectors.toList()); + + return WorkshopResponseDTO.WorkshopPreviewResponseDTO.builder() + .topNote(request.getTopNote()) + .middleNote(request.getMiddleNote()) + .baseNote(request.getBaseNote()) + .keywordSummary(workshopResult.getKeywordSummary()) + .firstImpression(workshopResult.getFirstImpression()) + .centerImpression(workshopResult.getCenterImpression()) + .lastImpression(workshopResult.getLastImpression()) + .tendency(workshopResult.getTendency()) + .recommendedFragranceJson(recommendedFragranceDTOList) + .build(); + } + /** JSON 문자열을 추천 향수 DTO 리스트로 변환 */ private static List parseFragranceJson(String fragranceJson) { if (fragranceJson == null || fragranceJson.trim().isEmpty()) { From 9a573855840ea1fa17bc6a1b87e4a45d5b8dacf2 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 27 Jul 2025 18:16:07 +0900 Subject: [PATCH 268/339] =?UTF-8?q?[feature]=20=ED=96=A5=EC=88=98=EA=B3=B5?= =?UTF-8?q?=EB=B0=A9=20=EA=B2=B0=EA=B3=BC=20=EC=83=9D=EC=84=B1=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20OpenAiService(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/service/openAi/OpenAiService.java | 5 +++ .../service/openAi/OpenAiServiceImpl.java | 39 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiService.java b/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiService.java index bfb68dc..a571894 100644 --- a/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiService.java +++ b/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiService.java @@ -4,5 +4,10 @@ public interface OpenAiService { // PBTI 구조화된 응답 반환 String getStructuredResponse(String prompt); + + // 향수공방 결과 생성 + String generateWorkshopResult(String topNote, Long topNoteVolume, + String middleNote, Long middleNoteVolume, + String baseNote, Long baseNoteVolume); } diff --git a/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiServiceImpl.java index 141e49e..9856f2b 100644 --- a/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiServiceImpl.java @@ -1,10 +1,16 @@ package PerfumeOnMe.spring.service.openAi; +import java.nio.file.Files; +import java.nio.file.Paths; + +import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @RequiredArgsConstructor @Transactional @@ -16,4 +22,37 @@ public class OpenAiServiceImpl implements OpenAiService { public String getStructuredResponse(String prompt) { return openAiApiClient.callChatGPT(prompt); // 실제 GPT 호출 로직 구현 } + + @Override + public String generateWorkshopResult(String topNote, Long topNoteVolume, + String middleNote, Long middleNoteVolume, + String baseNote, Long baseNoteVolume) { + try { + // 향수공방 프롬프트 파일 읽기 + ClassPathResource resource = new ClassPathResource("prompts/workshop.txt"); + String promptTemplate = Files.readString(Paths.get(resource.getURI())); + + // 프롬프트에 사용자 입력값 주입 + String prompt = promptTemplate + .replace("{topNoteType}", topNote) + .replace("{topNoteVolume}", String.valueOf(topNoteVolume)) + .replace("{middleNoteType}", middleNote) + .replace("{middleNoteVolume}", String.valueOf(middleNoteVolume)) + .replace("{baseNoteType}", baseNote) + .replace("{baseNoteVolume}", String.valueOf(baseNoteVolume)); + + log.info("향수공방 GPT 요청: topNote={} ({}), middleNote={} ({}), baseNote={} ({})", + topNote, topNoteVolume, middleNote, middleNoteVolume, baseNote, baseNoteVolume); + + // GPT API 호출 + String result = openAiApiClient.callChatGPT(prompt); + + log.info("향수공방 GPT 응답 생성 완료"); + return result; + + } catch (Exception e) { + log.error("향수공방 GPT 응답 생성 중 오류 발생: {}", e.getMessage(), e); + throw new RuntimeException("향수공방 결과 생성에 실패했습니다.", e); + } + } } From 94da41eb28cb39aff4a6f3a59830c9580e6c051a Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 27 Jul 2025 18:17:07 +0900 Subject: [PATCH 269/339] =?UTF-8?q?[feature]=20=ED=96=A5=EC=88=98=EA=B3=B5?= =?UTF-8?q?=EB=B0=A9=20=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index 95d8a12..b549d6b 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -99,6 +99,8 @@ public enum ErrorStatus implements BaseErrorCode { INVALID_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST, "S3IMAGE4001", "지원하지 않은 파일 확장자입니다."), // 향수공방 에러 + WORKSHOP_TOTAL_VOLUME_OVERFLOW(HttpStatus.BAD_REQUEST, "WORKSHOP4002", "선택한 노트들의 총 용량은 10 초과할 수 없습니다."), + EXPIRED_WORKSHOP_RESULT(HttpStatus.BAD_REQUEST, "WORKSHOP4003", "향수공방 미리보기 결과가 만료되었습니다. 다시 시도해주세요."), WORKSHOP_USER_NOT_MATCH(HttpStatus.BAD_REQUEST, "WORKSHOP4004", "해당 향수공방 결과에 접근할 수 없습니다."), WORKSHOP_ID_NULL(HttpStatus.UNAUTHORIZED, "WORKSHOP4005", "해당 향수공방 결과 정보가 존재하지 않습니다."), From 4886bffbac2a69a8b91f3a52a2b601af3afffb43 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 27 Jul 2025 18:18:12 +0900 Subject: [PATCH 270/339] =?UTF-8?q?[feature]=20=ED=96=A5=EC=88=98=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EA=B5=AC=ED=98=84=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkshopFragranceRepository.java | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/repository/WorkshopFragranceRepository.java diff --git a/src/main/java/PerfumeOnMe/spring/repository/WorkshopFragranceRepository.java b/src/main/java/PerfumeOnMe/spring/repository/WorkshopFragranceRepository.java new file mode 100644 index 0000000..12d50c8 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/repository/WorkshopFragranceRepository.java @@ -0,0 +1,44 @@ +package PerfumeOnMe.spring.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import PerfumeOnMe.spring.domain.WorkshopFragrance; + +public interface WorkshopFragranceRepository extends JpaRepository { + + // 노트로 향수 검색 (탑, 미들, 베이스 노트에서 키워드 포함) + @Query("SELECT wf FROM WorkshopFragrance wf WHERE " + + "wf.topNote LIKE %:note% OR " + + "wf.middleNote LIKE %:note% OR " + + "wf.baseNote LIKE %:note%") + List findByNoteContaining(@Param("note") String note); + + // 메인어코드로 향수 검색 (1,2,3순위에서 매칭) + @Query("SELECT wf FROM WorkshopFragrance wf WHERE " + + "wf.mainAccord1 = :accord OR " + + "wf.mainAccord2 = :accord OR " + + "wf.mainAccord3 = :accord") + List findByMainAccordContaining(@Param("accord") String accord); + + // 특정 노트들이 포함된 향수 검색 (복수 노트) + @Query("SELECT wf FROM WorkshopFragrance wf WHERE " + + "(:topNote IS NULL OR wf.topNote LIKE %:topNote%) AND " + + "(:middleNote IS NULL OR wf.middleNote LIKE %:middleNote%) AND " + + "(:baseNote IS NULL OR wf.baseNote LIKE %:baseNote%)") + List findByNotesContaining( + @Param("topNote") String topNote, + @Param("middleNote") String middleNote, + @Param("baseNote") String baseNote); + + // 가격 범위로 향수 검색 + @Query("SELECT wf FROM WorkshopFragrance wf WHERE wf.price BETWEEN :minPrice AND :maxPrice") + List findByPriceBetween(@Param("minPrice") Integer minPrice, @Param("maxPrice") Integer maxPrice); + + // 모든 향수 조회 (추천 알고리즘용) + @Query("SELECT wf FROM WorkshopFragrance wf ORDER BY wf.id") + List findAllForRecommendation(); +} \ No newline at end of file From bf75fd3419ff2852493760361e2667d050bdf7ee Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 27 Jul 2025 18:48:53 +0900 Subject: [PATCH 271/339] =?UTF-8?q?[feature]=20=ED=96=A5=EC=88=98=EA=B3=B5?= =?UTF-8?q?=EB=B0=A9=20=EC=A4=91=EB=B3=B5=EC=9D=B4=EB=A6=84=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index b549d6b..a91b440 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -103,6 +103,7 @@ public enum ErrorStatus implements BaseErrorCode { EXPIRED_WORKSHOP_RESULT(HttpStatus.BAD_REQUEST, "WORKSHOP4003", "향수공방 미리보기 결과가 만료되었습니다. 다시 시도해주세요."), WORKSHOP_USER_NOT_MATCH(HttpStatus.BAD_REQUEST, "WORKSHOP4004", "해당 향수공방 결과에 접근할 수 없습니다."), WORKSHOP_ID_NULL(HttpStatus.UNAUTHORIZED, "WORKSHOP4005", "해당 향수공방 결과 정보가 존재하지 않습니다."), + WORKSHOP_NAME_DUPLICATE(HttpStatus.BAD_REQUEST, "WORKSHOP4006", "이미 같은 이름으로 저장된 향수공방 결과가 있습니다."), // 예시,,, ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); From fe5a7ea941e9a5735875aad8b000aacf64cf9198 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 27 Jul 2025 18:53:14 +0900 Subject: [PATCH 272/339] =?UTF-8?q?[feature]=20=ED=96=A5=EC=88=98=EA=B3=B5?= =?UTF-8?q?=EB=B0=A9=20=EA=B2=B0=EA=B3=BC=20=EC=A0=80=EC=9E=A5=20DTO?= =?UTF-8?q?=EC=83=9D=EC=84=B1(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/dto/workshop/WorkshopResponseDTO.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java index 1df68b3..159ed44 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java @@ -66,12 +66,21 @@ public static class WorkshopPreviewResponseDTO { @Schema(description = "탑 노트", example = "베르가못") private String topNote; + @Schema(description = "탑 노트 용량", example = "3") + private Long topNoteVolume; + @Schema(description = "미들 노트", example = "장미") private String middleNote; + @Schema(description = "미들 노트 용량", example = "4") + private Long middleNoteVolume; + @Schema(description = "베이스 노트", example = "바닐라") private String baseNote; + @Schema(description = "베이스 노트 용량", example = "3") + private Long baseNoteVolume; + @Schema(description = "시각적 키워드 (해시태그 형태)", example = "#상큼한첫인상 #감성적중심 #우디잔향\n#깊이있는사람 #신뢰감있는향기") private String keywordSummary; @@ -115,4 +124,20 @@ public static class RecommendedFragranceDTO { private String imageUrl; } + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "향수공방 저장 결과") + public static class WorkshopSaveResponseDTO { + @Schema(description = "향수공방 아이디", example = "34") + private Long workshopId; + + @Schema(description = "향수공방 이름", example = "나만의 시나몬 겨울항기") + private String savedName; + + @Schema(description = "생성 날짜") + private LocalDateTime createdAt; + } + } From f6c68291054a9a016305831689329e55455c0272 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 27 Jul 2025 18:53:31 +0900 Subject: [PATCH 273/339] =?UTF-8?q?[feature]=20=ED=96=A5=EC=88=98=EA=B3=B5?= =?UTF-8?q?=EB=B0=A9=20=EA=B2=B0=EA=B3=BC=20=EC=A0=80=EC=9E=A5=20DTO?= =?UTF-8?q?=EC=83=9D=EC=84=B1(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/web/dto/workshop/WorkshopRequestDTO.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopRequestDTO.java index 9150b93..326fe24 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopRequestDTO.java @@ -65,4 +65,14 @@ public static class WorkshopCreateRequestDTO { @Schema(description = "베이스 노트 맵 (노트명: 용량)") private Map baseNoteList; } + + /**향수공방 결과 저장을 위한 요청 DTO*/ + @Builder + @Getter + @Schema(description = "향수공방 결과 저장을 위한 DTO") + public static class WorkshopSaveRequestDTO { + @Schema(description = "결과를 저장할 이름", example = "나만의 겨울향기") + @NotNull(message = "저장할 이름은 필수입니다") + private String savedName; + } } From 28cc1a921596d899b06799fb86621d8c5cbbec65 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 27 Jul 2025 18:54:10 +0900 Subject: [PATCH 274/339] =?UTF-8?q?[feature]=20=ED=96=A5=EC=88=98=EA=B3=B5?= =?UTF-8?q?=EB=B0=A9=20=EA=B2=B0=EA=B3=BC=20=EC=A0=80=EC=9E=A5=20Controlle?= =?UTF-8?q?r=EC=9E=91=EC=84=B1,=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/controller/WorkshopController.java | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java b/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java index 2e85650..9052d14 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java @@ -67,6 +67,45 @@ public ResponseEntity> saveWorkshopResult( + @Parameter(description = "향수공방 저장 요청", required = true) + @RequestBody @Valid WorkshopRequestDTO.WorkshopSaveRequestDTO request, + @Parameter(hidden = true) + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + return ResponseEntity.ok(ApiResponse.onSuccess(workshopService. + saveWorkshop(request, userDetails))); + } + /** 향수공방 목록 조회*/ @GetMapping("/result/list") @Operation( From 526d758bde4fd48f21418ccac87546b412b8242b Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 27 Jul 2025 18:55:28 +0900 Subject: [PATCH 275/339] =?UTF-8?q?[feature]=20=ED=96=A5=EC=88=98=EA=B3=B5?= =?UTF-8?q?=EB=B0=A9=20=EA=B2=B0=EA=B3=BC=20=EC=A0=80=EC=9E=A5,=20?= =?UTF-8?q?=EB=A0=88=EB=94=94=EC=8A=A4=EC=97=90=EC=84=9C=20=EA=B0=92=20?= =?UTF-8?q?=EA=B0=80=EC=A0=B8=EC=98=A4=EA=B8=B0=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EA=B5=AC=ED=98=84(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/converter/WorkshopConverter.java | 52 +++++++++++++++++++ .../workshop/WorkshopRepository.java | 2 + .../service/redis/WorkshopRedisService.java | 1 + .../service/workshop/WorkshopService.java | 50 ++++++++++++++++++ 4 files changed, 105 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java b/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java index 29a78ba..20e8529 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java @@ -8,6 +8,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import PerfumeOnMe.spring.domain.User; import PerfumeOnMe.spring.domain.Workshop; import PerfumeOnMe.spring.domain.WorkshopFragrance; import PerfumeOnMe.spring.service.workshop.WorkshopResult; @@ -54,8 +55,11 @@ public static WorkshopResponseDTO.WorkshopPreviewResponseDTO toWorkshopPreviewRe return WorkshopResponseDTO.WorkshopPreviewResponseDTO.builder() .topNote(request.getTopNote()) + .topNoteVolume(request.getTopNoteVolume()) .middleNote(request.getMiddleNote()) + .middleNoteVolume(request.getMiddleNoteVolume()) .baseNote(request.getBaseNote()) + .baseNoteVolume(request.getBaseNoteVolume()) .keywordSummary(workshopResult.getKeywordSummary()) .firstImpression(workshopResult.getFirstImpression()) .centerImpression(workshopResult.getCenterImpression()) @@ -85,8 +89,11 @@ public static WorkshopResponseDTO.WorkshopPreviewResponseDTO toWorkshopPreviewRe return WorkshopResponseDTO.WorkshopPreviewResponseDTO.builder() .topNote(request.getTopNote()) + .topNoteVolume(request.getTopNoteVolume()) .middleNote(request.getMiddleNote()) + .middleNoteVolume(request.getMiddleNoteVolume()) .baseNote(request.getBaseNote()) + .baseNoteVolume(request.getBaseNoteVolume()) .keywordSummary(workshopResult.getKeywordSummary()) .firstImpression(workshopResult.getFirstImpression()) .centerImpression(workshopResult.getCenterImpression()) @@ -96,6 +103,50 @@ public static WorkshopResponseDTO.WorkshopPreviewResponseDTO toWorkshopPreviewRe .build(); } + /** 향수공방 저장 응답 DTO 생성 */ + public static WorkshopResponseDTO.WorkshopSaveResponseDTO toWorkshopSaveResponse(Workshop workshop) { + return WorkshopResponseDTO.WorkshopSaveResponseDTO.builder() + .workshopId(workshop.getId()) + .savedName(workshop.getSavedName()) + .createdAt(workshop.getCreatedAt()) + .build(); + } + + /** Redis 미리보기 데이터를 Workshop 엔티티로 변환 */ + public static Workshop toWorkshopEntity( + User user, + String savedName, + WorkshopResponseDTO.WorkshopPreviewResponseDTO previewData, + String recommendedFragranceJson + ) { + return Workshop.builder() + .user(user) + .savedName(savedName) + .topNote(previewData.getTopNote()) + .topNoteVolume(previewData.getTopNoteVolume()) + .middleNote(previewData.getMiddleNote()) + .middleNoteVolume(previewData.getMiddleNoteVolume()) + .baseNote(previewData.getBaseNote()) + .baseNoteVolume(previewData.getBaseNoteVolume()) + .keywordSummary(previewData.getKeywordSummary()) + .firstImpression(previewData.getFirstImpression()) + .centerImpression(previewData.getCenterImpression()) + .lastImpression(previewData.getLastImpression()) + .tendency(previewData.getTendency()) + .recommendedFragranceJson(recommendedFragranceJson) + .build(); + } + + /** 추천 향수 리스트를 JSON 문자열로 변환 */ + public static String toRecommendedFragranceJson(List fragrances) { + try { + ObjectMapper objectMapper = new ObjectMapper(); + return objectMapper.writeValueAsString(fragrances); + } catch (JsonProcessingException e) { + return "[]"; // 빈 배열 반환 + } + } + /** JSON 문자열을 추천 향수 DTO 리스트로 변환 */ private static List parseFragranceJson(String fragranceJson) { if (fragranceJson == null || fragranceJson.trim().isEmpty()) { @@ -111,4 +162,5 @@ private static List parseFragranceJ return new ArrayList<>(); } } + } diff --git a/src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopRepository.java b/src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopRepository.java index 4bb23a4..b9f9d0d 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopRepository.java @@ -13,4 +13,6 @@ public interface WorkshopRepository extends JpaRepository { List findAllByUser(User user); Optional findByIdAndUser(Long id, User user); + + boolean existsByUserAndSavedName(User user, String savedName); } diff --git a/src/main/java/PerfumeOnMe/spring/service/redis/WorkshopRedisService.java b/src/main/java/PerfumeOnMe/spring/service/redis/WorkshopRedisService.java index ccf11a3..6567229 100644 --- a/src/main/java/PerfumeOnMe/spring/service/redis/WorkshopRedisService.java +++ b/src/main/java/PerfumeOnMe/spring/service/redis/WorkshopRedisService.java @@ -9,6 +9,7 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.web.dto.workshop.WorkshopRequestDTO; import PerfumeOnMe.spring.web.dto.workshop.WorkshopResponseDTO; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java b/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java index 2b07c87..4c56d0f 100644 --- a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java +++ b/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java @@ -55,6 +55,56 @@ public List findAllWorkshopsByUser( return WorkshopConverter.toWorkshopListResponse(workshops); } + @Transactional + public WorkshopResponseDTO.WorkshopSaveResponseDTO saveWorkshop( + WorkshopRequestDTO.WorkshopSaveRequestDTO request, CustomUserDetails userDetails + ) { + Long userId = userDetails.getUserId(); + + // 유저 ID 검증 + if (userId == null) { + throw new GeneralException(ErrorStatus.USER_ID_NULL); + } + + // 유저 존재 여부 검증 + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + + // savedName 중복 검증 + if (workshopRepository.existsByUserAndSavedName(user, request.getSavedName())) { + throw new GeneralException(ErrorStatus.WORKSHOP_NAME_DUPLICATE); + } + + log.info("향수공방 결과 저장 시작 - 사용자 ID: {}, 저장 이름: {}", userId, request.getSavedName()); + + // Redis에서 미리보기 결과 조회 + WorkshopResponseDTO.WorkshopPreviewResponseDTO previewData = + workshopRedisService.getPreview(userId); + + // 추천 향수 리스트 JSON 직렬화 + String recommendedFragranceJson = WorkshopConverter.toRecommendedFragranceJson( + previewData.getRecommendedFragranceJson() + ); + + // Workshop 엔티티 생성 및 저장 + Workshop workshop = WorkshopConverter.toWorkshopEntity( + user, + request.getSavedName(), + previewData, + recommendedFragranceJson + ); + + Workshop savedWorkshop = workshopRepository.save(workshop); + + // Redis 임시 데이터 삭제 + workshopRedisService.deletePreview(userId); + + log.info("향수공방 결과 저장 완료 - 사용자 ID: {}, 워크샵 ID: {}", userId, savedWorkshop.getId()); + + // 응답 DTO 생성 + return WorkshopConverter.toWorkshopSaveResponse(savedWorkshop); + } + @Transactional(readOnly = true) public WorkshopResponseDTO.WorkshopDetailResponseDTO findWorkshopById( Long workshopId, CustomUserDetails userDetails) { From 71e8589a719ffc1934b2043368cb2cb1afe50503 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 27 Jul 2025 18:57:12 +0900 Subject: [PATCH 276/339] =?UTF-8?q?[fix]=20=ED=99=98=EA=B2=BD=EB=B3=80?= =?UTF-8?q?=EC=88=98=20=EC=9E=84=EC=8B=9C=EA=B0=92=20=EC=B6=94=EA=B0=80(#9?= =?UTF-8?q?4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index f511f60..540394a 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -74,4 +74,5 @@ cloud: external: fastapi: - recommend-url: ${EXTERNAL_FASTAPI_RECOMMEND_URL:http://dummy-url.com} # 환경변수 없으면 기본값 사용(dummy-url.com) \ No newline at end of file + recommend-url: ${EXTERNAL_FASTAPI_RECOMMEND_URL:http://dummy-url.com} # 환경변수 없으면 기본값 사용(dummy-url.com) + pbti-recommend-url: ${EXTERNAL_FASTAPI_RECOMMEND_URL:http://dummy-url.com} # 환경변수 없으면 기본값 사용(dummy-url.com) \ No newline at end of file From 3879b907428d8591d98af1ab6bde8112d8449843 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Tue, 29 Jul 2025 21:26:22 +0900 Subject: [PATCH 277/339] =?UTF-8?q?[Fix]=20=EC=9E=84=EC=8B=9C=EB=A1=9C=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EB=A7=8C=EB=A3=8C=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/security/auth/provider/JwtTokenProvider.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtTokenProvider.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtTokenProvider.java index 55c5f94..1d82ae3 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtTokenProvider.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtTokenProvider.java @@ -50,8 +50,8 @@ public String createAccessToken(Authentication authentication) { .claim("userId", userId) .claim("name", name) .setIssuedAt(new Date()) - .setExpiration(new Date( - System.currentTimeMillis() + jwtProperties.getExpiration().getAccess())) + // .setExpiration(new Date( + // System.currentTimeMillis() + jwtProperties.getExpiration().getAccess())) .signWith(signingKey) .compact(); } @@ -66,8 +66,8 @@ public String createRefreshToken(Authentication authentication) { .setSubject(loginId) .claim("userId", userId) .setIssuedAt(new Date()) - .setExpiration(new Date( - System.currentTimeMillis() + jwtProperties.getExpiration().getRefresh())) + // .setExpiration(new Date( + // System.currentTimeMillis() + jwtProperties.getExpiration().getRefresh())) .signWith(signingKey) .compact(); } From b317b07e91767fe5455dee1c98b9f7fd00c88695 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Tue, 29 Jul 2025 21:41:44 +0900 Subject: [PATCH 278/339] =?UTF-8?q?[Fix]=20=EC=95=A1=EC=84=B8=EC=8A=A4=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EB=A7=8C=EB=A3=8C=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?2=EC=A3=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/security/auth/provider/JwtTokenProvider.java | 8 ++++---- src/main/resources/application-dev.yml | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtTokenProvider.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtTokenProvider.java index 1d82ae3..55c5f94 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtTokenProvider.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtTokenProvider.java @@ -50,8 +50,8 @@ public String createAccessToken(Authentication authentication) { .claim("userId", userId) .claim("name", name) .setIssuedAt(new Date()) - // .setExpiration(new Date( - // System.currentTimeMillis() + jwtProperties.getExpiration().getAccess())) + .setExpiration(new Date( + System.currentTimeMillis() + jwtProperties.getExpiration().getAccess())) .signWith(signingKey) .compact(); } @@ -66,8 +66,8 @@ public String createRefreshToken(Authentication authentication) { .setSubject(loginId) .claim("userId", userId) .setIssuedAt(new Date()) - // .setExpiration(new Date( - // System.currentTimeMillis() + jwtProperties.getExpiration().getRefresh())) + .setExpiration(new Date( + System.currentTimeMillis() + jwtProperties.getExpiration().getRefresh())) .signWith(signingKey) .compact(); } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index f511f60..d80613b 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -46,8 +46,8 @@ jwt: token: secretKey: ${JWT_SECRET:default_dummy} expiration: - access: 7200000 # 2H - refresh: 1209600000 # 2Week + access: 1209600000 # 2H(7200000) -> 연동 끝나면 수정 + refresh: 1209600000 # 2Week(1209600000) server: servlet: encoding: From 7283a0cfa798ecc376d1c5e8d216532f6075a085 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Tue, 29 Jul 2025 22:03:00 +0900 Subject: [PATCH 279/339] temp --- src/main/resources/application-dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 540394a..e3e9859 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -22,7 +22,7 @@ spring: show-sql: true data: redis: - host: ${REDIS_HOST} + host: ${REDIS_HOST} #수정테스트 port: ${REDIS_PORT:6379} cache: type: redis From 7a7b2b519421eb616b8be04bea186dfe0ae41687 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Tue, 29 Jul 2025 22:47:52 +0900 Subject: [PATCH 280/339] [Refactor] redis (#94) --- .github/workflows/dev_deploy.yml | 4 ++-- src/main/resources/application-dev.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index 4554ee1..616d88f 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -82,8 +82,8 @@ jobs: -e DB_URL=${{ secrets.ENV_DB_URL }} \ -e DB_USERNAME=${{ secrets.ENV_DB_USERNAME }} \ -e DB_PASSWORD=${{ secrets.ENV_DB_PASSWORD }} \ - -e REDIS_HOST=${{ secrets.ENV_REDIS_HOST }} \ - -e REDIS_PORT=${{ secrets.ENV_REDIS_PORT }} \ + -e SPRING_DATA_REDIS_HOST=${{ secrets.ENV_REDIS_HOST }} \ + -e SPRING_DATA_REDIS_PORT=${{ secrets.ENV_REDIS_PORT }} \ -e JWT_SECRET=${{ secrets.ENV_JWT_SECRET }} \ -e OPENAI_API_KEY=${{ secrets.OPENAI_API_KEY }} \ -e KAKAO_REST_API_KEY=${{ secrets.KAKAO_REST_API_KEY }} \ diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index e3e9859..f7e480e 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -22,7 +22,7 @@ spring: show-sql: true data: redis: - host: ${REDIS_HOST} #수정테스트 + host: ${REDIS_HOST:172.17.0.1} port: ${REDIS_PORT:6379} cache: type: redis From 89f6b09fccf56c003cca8fb0f5c07a19f9171a25 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Wed, 30 Jul 2025 00:10:58 +0900 Subject: [PATCH 281/339] =?UTF-8?q?[fix]=20redis=EC=84=A4=EC=A0=95?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#107)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/PerfumeOnMe/spring/config/RedisConfig.java | 9 +++++---- src/main/resources/application-dev.yml | 10 ++++++++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/config/RedisConfig.java b/src/main/java/PerfumeOnMe/spring/config/RedisConfig.java index 8aaafc1..1eb955d 100644 --- a/src/main/java/PerfumeOnMe/spring/config/RedisConfig.java +++ b/src/main/java/PerfumeOnMe/spring/config/RedisConfig.java @@ -11,11 +11,12 @@ public class RedisConfig { /* RedisConnectionFactory의 구현체로 LettuceConnectionFactory 사용 및 빈 등록 + Spring Boot 자동 설정을 사용하기 위해 주석 처리 */ - @Bean - public RedisConnectionFactory redisConnectionFactory() { - return new LettuceConnectionFactory(); - } + // @Bean + // public RedisConnectionFactory redisConnectionFactory() { + // return new LettuceConnectionFactory(); + // } /* Key-Value를 String-String으로 저장하는 StringRedisTemplate 빈 등록 diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 5a7e7c7..c13adcf 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -22,8 +22,14 @@ spring: show-sql: true data: redis: - host: ${REDIS_HOST:172.17.0.1} - port: ${REDIS_PORT:6379} + host: ${SPRING_DATA_REDIS_HOST:host.docker.internal} + port: ${SPRING_DATA_REDIS_PORT:6379} + timeout: 2000ms + lettuce: + pool: + max-active: 8 + max-idle: 8 + min-idle: 0 cache: type: redis security: From d2a5a9ed19134f14b376c52044390a8e787802be Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Wed, 30 Jul 2025 00:14:38 +0900 Subject: [PATCH 282/339] =?UTF-8?q?[fix]=20=20--add-host=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=EC=B6=94=EA=B0=80=EB=A1=9C=20=ED=98=B8=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=20=ED=97=88=EC=9A=A9=20(#107)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev_deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index 616d88f..b3d85b1 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -78,6 +78,7 @@ jobs: echo "🚀 Starting new container with the following environment variables:" sudo docker run -d -p 8080:8080 --name perfumeonme \ + --add-host=host.docker.internal:host-gateway \ -e SPRING_PROFILES_ACTIVE=dev \ -e DB_URL=${{ secrets.ENV_DB_URL }} \ -e DB_USERNAME=${{ secrets.ENV_DB_USERNAME }} \ From 0e8b17e81aed87359175e6f755586637d10774aa Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Wed, 30 Jul 2025 16:52:46 +0900 Subject: [PATCH 283/339] =?UTF-8?q?[Fix]=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EC=8B=9C=ED=8A=B8=20=EA=B4=80=EB=A0=A8=20=EB=B3=80=EA=B2=BD(#1?= =?UTF-8?q?09)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/GeneralException.java | 5 +++++ .../PerfumeOnMe/spring/domain/Fragrance.java | 4 ++-- .../spring/domain/enums/Brand.java | 9 ++++++++- .../fragranceInit/FragranceImportService.java | 2 +- .../fragranceInit/FragranceRowProcessor.java | 10 +++++++++- src/main/resources/data/perfumeOnMe_data.xlsx | Bin 124531 -> 223162 bytes src/main/resources/prompts/expert.txt | 2 +- 7 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/exception/GeneralException.java b/src/main/java/PerfumeOnMe/spring/apiPayload/exception/GeneralException.java index 3d97bf1..1b95dfa 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/exception/GeneralException.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/exception/GeneralException.java @@ -12,6 +12,11 @@ public class GeneralException extends RuntimeException { private BaseErrorCode code; + public GeneralException(BaseErrorCode code, String message) { + super(message); // 로그에 메시지가 나오게 됨 + this.code = code; + } + public ErrorStatus getErrorStatus() { if (this.code instanceof ErrorStatus) { return (ErrorStatus)this.code; diff --git a/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java b/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java index 31ebddd..54d1f66 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java @@ -53,7 +53,7 @@ public class Fragrance extends BaseEntity { private String name; @Enumerated(EnumType.STRING) - @Column(columnDefinition = "VARCHAR(15)") + @Column(columnDefinition = "VARCHAR(50)") private Brand brand; @Column(nullable = false) @@ -91,7 +91,7 @@ public class Fragrance extends BaseEntity { @Column(nullable = false) private String baseNoteKeyword; - @Column(nullable = false, unique = true, length = 30) + @Column(nullable = false, unique = true) private String keyword; //----- 매핑 관계 ----- diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Brand.java b/src/main/java/PerfumeOnMe/spring/domain/enums/Brand.java index b3dc37a..e5aa9a8 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/Brand.java +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/Brand.java @@ -10,7 +10,14 @@ public enum Brand { DIPTYQUE("딥티크 (DIPTYQUE)"), JOMALONE("조 말론 (JOMALONE)"), MAISON_MARGIELA("메종 마르지엘라 (MAISON MARGIELA)"), - FREDERIC_MALLE("프레데릭 말 (FREDERIC MALLE)"); + FREDERIC_MALLE("프레데릭 말 (FREDERIC MALLE)"), + BYREDO("바이레도 (BYREDO)"), + TOM_FORD("톰 포드 (TOM FORD)"), + AESOP("이솝 (AESOP)"), + YVES_SAINT_LAURENT("입생로랑 (YVES SAINT LAURENT)"), + LE_LABO("르 라보 (LE LABO)"), + VERSACE("베르사체 (VERSACE)"), + MAISON_FRANCIS_KURKDJIAN("메종 프란시스 커정 (MAISON FRANCIS_KURKDJIAN)"); private final String showBrand; } diff --git a/src/main/java/PerfumeOnMe/spring/service/fragranceInit/FragranceImportService.java b/src/main/java/PerfumeOnMe/spring/service/fragranceInit/FragranceImportService.java index 676dbf6..0a2a91a 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragranceInit/FragranceImportService.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragranceInit/FragranceImportService.java @@ -109,7 +109,7 @@ private void importPriceFromRow(Row row) { } }); } catch (NumberFormatException e) { - throw new GeneralException(ErrorStatus.PRICE_PARSING_ERROR); // 파싱 실패 로그 + throw new GeneralException(ErrorStatus.PRICE_PARSING_ERROR, "가격 파싱 실패: " + priceStr); // 파싱 실패 로그 } } diff --git a/src/main/java/PerfumeOnMe/spring/service/fragranceInit/FragranceRowProcessor.java b/src/main/java/PerfumeOnMe/spring/service/fragranceInit/FragranceRowProcessor.java index 03114bc..b610e4e 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragranceInit/FragranceRowProcessor.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragranceInit/FragranceRowProcessor.java @@ -210,7 +210,15 @@ private Brand convertToBrand(String brandStr) { case "LOIVIE" -> Brand.LOIVIE; case "DIPTYQUE" -> Brand.DIPTYQUE; case "JOMALONE" -> Brand.JOMALONE; - default -> throw new GeneralException(ErrorStatus.UNSUPPORTED_BRAND); + case "BYREDO" -> Brand.BYREDO; + case "TOM FORD" -> Brand.TOM_FORD; + case "AESOP" -> Brand.AESOP; + case "YVES SAINT LAURENT" -> Brand.YVES_SAINT_LAURENT; + case "LE LABO" -> Brand.LE_LABO; + case "VERSACE" -> Brand.VERSACE; + case "메종 프란시스 커정" -> Brand.MAISON_FRANCIS_KURKDJIAN; + + default -> throw new GeneralException(ErrorStatus.UNSUPPORTED_BRAND, "지원하지 않는 브랜드: " + brandStr); }; } diff --git a/src/main/resources/data/perfumeOnMe_data.xlsx b/src/main/resources/data/perfumeOnMe_data.xlsx index 888dd58f25882ef767e63b3cb7d163b1fb676209..e5bff8c2633256f93c7ef9979fc0906d6e3f9751 100644 GIT binary patch literal 223162 zcmeFYgFG|q@aW}(jC$rf;5}lbW3-aARtJ0vq3@{qyz~8X{1BCL8M#h=AGd0 zInQ~%bFS|ncweq-?X~7y`p4F~HCCfU@*cKc{(S{NzzAHGY7ym<~Lj4t(X(&y#!+ErFJRZ1lB~$E`w+QI!GO zhGr!Ao={DL@Rzkdz3DL=Y>yVUU7mlfqIrj6X~8-5QN&-Y-&I;){m5gvhWK4Yl~bQ4&RvQZa1|NWkgfBqEruexo@g_#XXDQzbhJ zj_}B_WnxNTbwL9)uHyHS{FsYE)5u%P>z%$CEjR6zmxMlb;_ISxOb^6LzkD^TF6?Jv zqdjxvIQZ(K!#$Gr0@Yw6=T!0S+-+?1OLRm*{qh$w34Bnv$JXdmC+7GPL$Pv8qTUFK zBop<1nKR~ZrFD3&qikzi6i#t6f(E8^f;riQboB$~rERjIQer<}J-W>DC_FtCPp#3= zAW3~w_L9W#@az`$l7b zY27LmoLFbJtrscZF!OPAWk&q4^_I_OF0*D zaV)j4Fp|fJuQOjm&@WM9Xt;^MzE?@Nl(m948f(P_HpXaku!}mB52sWZ>7b? z{qf*QSU`T|LrhK@tc`Wdz5_yLlo~&|kgs&?ls{zg%_%H%^Rwx2NtsYS>am*zC)r9~q=COe(iREfosxfDz!tA2D`o~RZ&AI%%0DUdIM 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 x`C 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 + +--- + +## 📌 프로젝트 소개 + +퍼퓨온미는 사용자가 자신에게 어울리는 향수를 쉽고 재미있게 찾을 수 있도록 돕는 향수 추천·경험 플랫폼입니다. +GPT 기반 분석, 키워드 검색, 설문 등 다양한 방법을 통해 사용자의 취향을 파악하고, +성격·기분·스타일에 맞춘 개인 맞춤형 향수 추천을 제공합니다. +이를 통해 단순한 제품 구매를 넘어, 향수를 통해 추억과 감정을 담아내는 새로운 경험을 제안합니다. + +--- + +## 🌱 프로젝트 배경 + +수천 가지 향수가 존재하지만, 대부분의 사람들은 어떤 향이 자신에게 어울릴지 몰라 선택에 어려움을 겪습니다. +또한 향에 대한 취향은 언어로 설명하기 어려워 기존의 검색·추천 방식에는 한계가 있습니다. +퍼퓨온미는 이러한 문제를 해결하고자, 다양한 접근 방식과 개인화 추천을 결합한 플랫폼을 만들었습니다. +향수를 비싸고 어려운 액세서리가 아닌, 누구나 즐길 수 있는 일상의 취미로 바꾸는 것이 우리의 목표입니다. + +--- + +## 🔗 배포 주소 + +> [🌐 퍼퓨온미 바로가기](https://perfuonme.example.com) + +--- + +## ✨ 주요 기능 + +- 💡 **취향 맞춤 추천** : 취향 기반 개인 맞춤 향수 추천 +- 📚 **향수 아카이브** : 성별, 상황, 계절, 가격, 노트별 등 검색 및 필터 +- 🧾 **시향 기록** : 향에 대한 개인 다이어리 기록 +- 📱 **추천 컨텐츠** : 이미지 기반, 온라인 공방, PBTI 등 다양한 경로의 추천 + +--- + +## 🎥 데모 & 미리보기 + +| 메인 화면 | 향수 상세 | 추천 화면 | +|-------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------| +| Image | Image | Image | + +![기능 시연](images/demo.gif) + +--- + +--- + +## 🌿 브랜치 전략 + +본 프로젝트는 **Git Flow** 브랜치 전략을 기반으로 운영됩니다. + +- `main` : 실제 배포 버전이 반영되는 브랜치 +- `develop` : 개발이 진행되는 메인 브랜치 +- `feature` : 기능 단위 개발 브랜치 +- `fix` : 기능 단위 수정 브랜치 +- `refactor` : 코드 리팩터링 브랜치 +- `ci-cd` : CI/CD 브랜치 +- `style` : 기능에 영향을 주지 않는 수정 브랜치 +- `hotfix` : 배포 중 긴급 수정 브랜치 + +> 모든 PR은 `develop` 브랜치로 머지되며, 코드 리뷰 후 승인 절차를 거칩니다. + +--- + +## 🛠 기술 스택 + +**Backend** + +- `Java`: 21 +- `JDK`: 21.0.2 +- `Build`: Gradle 8.14.2 +- `IDE`: IntelliJ IDEA 2024.1 +- `Framework`: Spring Boot 3.5.3, FastAPI +- `Database`: MySQL (AWS RDS), Redis, AWS S3 +- `ORM`: Spring Data JPA +- `CI/CD`: Github Actions (CI/CD) + Docker + +**협업 도구** + +- `Git/GitHub` +- `Notion` +- `Figma` +- `Slack` +- `Discord` + +--- + +## 🧭 서버 아키텍처 다이어그램 + +![Image](https://github.com/user-attachments/assets/4247179d-01f8-4c24-953e-1df7d008787d) + +--- + +## 📂 프로젝트 구조 + +``` +└── 📁src + └── 📁main + └── 📁java + └── 📁PerfumeOnMe + └── 📁spring + └── 📁apiPayload + └── 📁code + └── 📁status + └── 📁exception + └── 📁chatbot + └── 📁converter + └── 📁domain + └── 📁repository + └── 📁service + └── 📁web + └── 📁controller + └── 📁dto + └── 📁common + └── 📁base + └── 📁config + └── 📁properties + └── 📁controller + └── 📁enums + └── 📁fragranceInit + └── 📁util + └── 📁validation + └── 📁annotation + └── 📁validator + └── 📁diary + └── 📁converter + └── 📁domain + └── 📁repository + └── 📁service + └── 📁web + └── 📁controller + └── 📁dto + └── 📁external + └── 📁fastapi + └── 📁dto + └── 📁openai + └── 📁fragrance + └── 📁converter + └── 📁domain + └── 📁mapping + └── 📁repository + └── 📁fragranceBaseNote + └── 📁fragranceLocation + └── 📁fragranceMiddleNote + └── 📁fragrancePrice + └── 📁fragranceSeason + └── 📁fragranceTopNote + └── 📁location + └── 📁note + └── 📁price + └── 📁season + └── 📁service + └── 📁validation + └── 📁annotation + └── 📁validator + └── 📁web + └── 📁controller + └── 📁dto + └── 📁imagekeyword + └── 📁converter + └── 📁domain + └── 📁redis + └── 📁repository + └── 📁imagekeyworddescription + └── 📁service + └── 📁util + └── 📁validation + └── 📁annotation + └── 📁validator + └── 📁web + └── 📁controller + └── 📁docs + └── 📁dto + └── 📁pbti + └── 📁converter + └── 📁domain + └── 📁repository + └── 📁service + └── 📁web + └── 📁controller + └── 📁dto + └── 📁s3file + └── 📁aws + └── 📁converter + └── 📁web + └── 📁controller + └── 📁dto + └── 📁security + └── 📁auth + └── 📁controller + └── 📁converter + └── 📁dto + └── 📁filter + └── 📁handler + └── 📁manager + └── 📁provider + └── 📁service + └── 📁token + └── 📁userDetails + └── 📁oauth + └── 📁controller + └── 📁converter + └── 📁dto + └── 📁service + └── 📁util + └── 📁user + └── 📁converter + └── 📁domain + └── 📁mapping + └── 📁repository + └── 📁userFragrance + └── 📁userNote + └── 📁service + └── 📁validation + └── 📁annotation + └── 📁validator + └── 📁web + └── 📁controller + └── 📁dto + └── 📁uuid + └── 📁domain + └── 📁repository + └── 📁workshop + └── 📁converter + └── 📁domain + └── 📁redis + └── 📁repository + └── 📁service + └── 📁validation + └── 📁annotation + └── 📁validator + └── 📁web + └── 📁controller + └── 📁docs + └── 📁dto + └── 📁resources + └── 📁data + └── 📁prompts + └── 📁test + └── 📁java + └── 📁PerfumeOnMe + └── 📁spring +``` + +## 📅 Roadmap + +- [ ] 향수 데이터 추가 +- [ ] 퍼퓸다이어리 공유 기능 +- [ ] 마이페이지 일부 기능 추가 +- [ ] 향수 추천 알고리즘 고도화 +- [ ] 모바일 앱 버전 출시 + +--- + +## 👥 팀원 정보 + +| 이름 | 역할 | GitHub | +|-----|---------|------------------------------------------------------| +| 김은지 | Backend | [@hcg0127](https://github.com/hcg0127) | +| 김찬우 | Backend | [@chanudevelop](https://github.com/chanudevelop) | +| 이병웅 | Backend | [@bulee5328](https://github.com/bulee5328) | +| 이원희 | Backend | [@leewonhee-3054](https://github.com/leewonhee-3054) | + +--- + +## 📬 연락처 + +인스타그램: perfu_on_me + +--- + From c39f47c162d41c647e63e64f30d75c9999ed7cf7 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Thu, 14 Aug 2025 22:47:02 +0900 Subject: [PATCH 324/339] =?UTF-8?q?[Refactor]=20=EC=8A=A4=EC=9B=A8?= =?UTF-8?q?=EA=B1=B0=20=EB=AC=B8=EC=84=9C=20=EB=B6=84=EB=A6=AC=20(#135)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/controller/ChatbotController.java | 24 +-- .../web/docs/ChatbotControllerDocs.java | 45 ++++++ .../diary/web/controller/DiaryController.java | 54 +------ .../diary/web/docs/DiaryControllerDocs.java | 91 +++++++++++ .../web/controller/FragranceController.java | 103 +------------ .../web/docs/FragranceControllerDocs.java | 145 ++++++++++++++++++ .../pbti/web/controller/PbtiController.java | 55 +------ .../pbti/web/docs/PbtiControllerDocs.java | 90 +++++++++++ .../s3file/web/controller/S3Controller.java | 24 +-- .../s3file/web/docs/S3ControllerDocs.java | 35 +++++ .../auth/controller/LoginController.java | 14 +- .../auth/docs/LoginControllerDocs.java | 30 ++++ .../oauth/controller/OAuthController.java | 17 +- .../oauth/docs/OAuthControllerDocs.java | 36 +++++ .../user/web/controller/UserController.java | 84 +--------- .../user/web/docs/UserControllerDocs.java | 129 ++++++++++++++++ 16 files changed, 617 insertions(+), 359 deletions(-) create mode 100644 src/main/java/PerfumeOnMe/spring/chatbot/web/docs/ChatbotControllerDocs.java create mode 100644 src/main/java/PerfumeOnMe/spring/diary/web/docs/DiaryControllerDocs.java create mode 100644 src/main/java/PerfumeOnMe/spring/fragrance/web/docs/FragranceControllerDocs.java create mode 100644 src/main/java/PerfumeOnMe/spring/pbti/web/docs/PbtiControllerDocs.java create mode 100644 src/main/java/PerfumeOnMe/spring/s3file/web/docs/S3ControllerDocs.java create mode 100644 src/main/java/PerfumeOnMe/spring/security/auth/docs/LoginControllerDocs.java create mode 100644 src/main/java/PerfumeOnMe/spring/security/oauth/docs/OAuthControllerDocs.java create mode 100644 src/main/java/PerfumeOnMe/spring/user/web/docs/UserControllerDocs.java diff --git a/src/main/java/PerfumeOnMe/spring/chatbot/web/controller/ChatbotController.java b/src/main/java/PerfumeOnMe/spring/chatbot/web/controller/ChatbotController.java index 44bdbee..314fb27 100644 --- a/src/main/java/PerfumeOnMe/spring/chatbot/web/controller/ChatbotController.java +++ b/src/main/java/PerfumeOnMe/spring/chatbot/web/controller/ChatbotController.java @@ -12,14 +12,9 @@ import PerfumeOnMe.spring.apiPayload.ApiResponse; import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; import PerfumeOnMe.spring.chatbot.service.ChatbotService; +import PerfumeOnMe.spring.chatbot.web.docs.ChatbotControllerDocs; import PerfumeOnMe.spring.chatbot.web.dto.ChatBotRequestDTO; import PerfumeOnMe.spring.chatbot.web.dto.ChatBotResponseDTO; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.Parameters; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import reactor.core.publisher.Mono; @@ -27,20 +22,12 @@ @RestController @RequiredArgsConstructor @RequestMapping("/chatbot") -@Tag(name = "Chatbot", description = "챗봇 CRUD API") -public class ChatbotController { +public class ChatbotController implements ChatbotControllerDocs { private final ChatbotService chatbotService; // 로그인한 사용자가 챗봇에게 질문을 보내고, // OpenAI 로부터 받은 응답을 클라이언트에게 반환하는 API @PostMapping - @Operation( - summary = "챗봇 질의 응답", - description = "챗봇에게 질문을 하고 OpenAI 로부터 받은 응답을 반환하는 API 입니다." - ) - @Parameters({ - @Parameter(name = "message", description = "사용자가 질문할 내용"), - }) public Mono>> ask( @RequestBody ChatBotRequestDTO.ChatBotQARequest request, @AuthenticationPrincipal CustomUserDetails userDetails) { @@ -51,13 +38,6 @@ public Mono>> ask( // 대화 이력을 반환하는 API @GetMapping("/history") - @Operation( - summary = "챗봇 대화 이력 조회", - description = "챗봇 대화 이력을 조회하는 API 입니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ChatBotResponseDTO.ChatBotQA.class))), - } - ) public ResponseEntity> getHistory( @Valid @ModelAttribute ChatBotRequestDTO.ChatBotPagingRequest request, @AuthenticationPrincipal CustomUserDetails userDetails) { diff --git a/src/main/java/PerfumeOnMe/spring/chatbot/web/docs/ChatbotControllerDocs.java b/src/main/java/PerfumeOnMe/spring/chatbot/web/docs/ChatbotControllerDocs.java new file mode 100644 index 0000000..03e1f07 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/chatbot/web/docs/ChatbotControllerDocs.java @@ -0,0 +1,45 @@ +package PerfumeOnMe.spring.chatbot.web.docs; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestBody; + +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; +import PerfumeOnMe.spring.chatbot.web.dto.ChatBotRequestDTO; +import PerfumeOnMe.spring.chatbot.web.dto.ChatBotResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import reactor.core.publisher.Mono; + +@Tag(name = "Chatbot", description = "챗봇 CRUD API") +public interface ChatbotControllerDocs { + + @Operation( + summary = "챗봇 질의 응답", + description = "챗봇에게 질문을 하고 OpenAI 로부터 받은 응답을 반환하는 API 입니다." + ) + @Parameters({ + @Parameter(name = "message", description = "사용자가 질문할 내용"), + }) + Mono>> ask( + @RequestBody ChatBotRequestDTO.ChatBotQARequest request, + @AuthenticationPrincipal CustomUserDetails userDetails); + + @Operation( + summary = "챗봇 대화 이력 조회", + description = "챗봇 대화 이력을 조회하는 API 입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ChatBotResponseDTO.ChatBotQA.class))), + } + ) + ResponseEntity> getHistory( + @Valid @ModelAttribute ChatBotRequestDTO.ChatBotPagingRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails); +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/diary/web/controller/DiaryController.java b/src/main/java/PerfumeOnMe/spring/diary/web/controller/DiaryController.java index b928935..b10cebe 100644 --- a/src/main/java/PerfumeOnMe/spring/diary/web/controller/DiaryController.java +++ b/src/main/java/PerfumeOnMe/spring/diary/web/controller/DiaryController.java @@ -17,35 +17,22 @@ import PerfumeOnMe.spring.apiPayload.ApiResponse; import PerfumeOnMe.spring.apiPayload.code.status.SuccessStatus; import PerfumeOnMe.spring.diary.service.DiaryService; +import PerfumeOnMe.spring.diary.web.docs.DiaryControllerDocs; import PerfumeOnMe.spring.diary.web.dto.DiaryRequestDTO; import PerfumeOnMe.spring.diary.web.dto.DiaryResponseDTO; import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor @RequestMapping("/diary") -@Tag(name = "Diary", description = "다이어리 CRUD API") -public class DiaryController { +public class DiaryController implements DiaryControllerDocs { private final DiaryService diaryService; // 다이어리 추가 API @PostMapping("/write") - @Operation( - summary = "다이어리 추가", - description = "다이어리를 추가하는 API입니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "다이어리가 저장되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = DiaryResponseDTO.AddDiaryResponse.class))), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON401", description = "액세스 토큰을 입력해 주세요.") - } - ) public ResponseEntity> addDiary( @RequestBody @Valid DiaryRequestDTO.AddDiaryRequest request, @AuthenticationPrincipal CustomUserDetails userDetails) { @@ -55,15 +42,6 @@ public ResponseEntity> addDiary( // 다이어리 수정 API @PatchMapping("/{diaryId}/update") - @Operation( - summary = "다이어리 수정", - description = "다이어리를 수정하는 API입니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "다이어리가 수정되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ApiResponse.class))), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "DIARY4001", description = "해당 다이어리를 찾을 수 없습니다."), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "DIARY4002", description = "다이어리 소유자의 요청이 아닙니다.") - } - ) public ResponseEntity> updateDiary( @PathVariable Long diaryId, @RequestBody @Valid DiaryRequestDTO.UpdateDiaryRequest request, @@ -74,15 +52,6 @@ public ResponseEntity> updateDiary( // 다이어리 삭제 API @DeleteMapping("/{diaryId}/delete") - @Operation( - summary = "다이어리 삭제", - description = "다이어리를 삭제하는 API입니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "다이어리가 삭제되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ApiResponse.class))), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "DIARY4001", description = "해당 다이어리를 찾을 수 없습니다."), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "DIARY4002", description = "다이어리 소유자의 요청이 아닙니다.") - } - ) public ResponseEntity> deleteDiary( @PathVariable Long diaryId, @AuthenticationPrincipal CustomUserDetails userDetails) { @@ -92,18 +61,7 @@ public ResponseEntity> deleteDiary( // 일별 다이어리 상세 조회 API @GetMapping("/daily/{date}") - @Operation( - summary = "일별 다이어리 상세 조회", - description = "일별 다이어리를 상세 조회하는 API입니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "다이어리가 조회되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = DiaryResponseDTO.SearchDailyDiaryResponse.class))), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "DIARY4003", description = "해당 날짜에 해당하는 다이어리를 찾을 수 없습니다.") - } - ) public ResponseEntity>> searchDailyDiary( - @Parameter( - description = "조회할 날짜 (예: 2025-07-17)" - ) @PathVariable LocalDate date, @AuthenticationPrincipal CustomUserDetails userDetails) { List result = diaryService.searchDailyDiary(userDetails.getUserId(), @@ -113,14 +71,6 @@ public ResponseEntity>> searchMonthlyDiary( @PathVariable Integer year, @PathVariable Integer month, diff --git a/src/main/java/PerfumeOnMe/spring/diary/web/docs/DiaryControllerDocs.java b/src/main/java/PerfumeOnMe/spring/diary/web/docs/DiaryControllerDocs.java new file mode 100644 index 0000000..945a1fe --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/diary/web/docs/DiaryControllerDocs.java @@ -0,0 +1,91 @@ +package PerfumeOnMe.spring.diary.web.docs; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.diary.web.dto.DiaryRequestDTO; +import PerfumeOnMe.spring.diary.web.dto.DiaryResponseDTO; +import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "Diary", description = "다이어리 CRUD API") +public interface DiaryControllerDocs { + + @Operation( + summary = "다이어리 추가", + description = "다이어리를 추가하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "다이어리가 저장되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = DiaryResponseDTO.AddDiaryResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON401", description = "액세스 토큰을 입력해 주세요.") + } + ) + ResponseEntity> addDiary( + @RequestBody @Valid DiaryRequestDTO.AddDiaryRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails); + + @Operation( + summary = "다이어리 수정", + description = "다이어리를 수정하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "다이어리가 수정되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "DIARY4001", description = "해당 다이어리를 찾을 수 없습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "DIARY4002", description = "다이어리 소유자의 요청이 아닙니다.") + } + ) + ResponseEntity> updateDiary( + @PathVariable Long diaryId, + @RequestBody @Valid DiaryRequestDTO.UpdateDiaryRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails); + + @Operation( + summary = "다이어리 삭제", + description = "다이어리를 삭제하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "다이어리가 삭제되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = ApiResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "DIARY4001", description = "해당 다이어리를 찾을 수 없습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "DIARY4002", description = "다이어리 소유자의 요청이 아닙니다.") + } + ) + ResponseEntity> deleteDiary( + @PathVariable Long diaryId, + @AuthenticationPrincipal CustomUserDetails userDetails); + + @Operation( + summary = "일별 다이어리 상세 조회", + description = "일별 다이어리를 상세 조회하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "다이어리가 조회되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = DiaryResponseDTO.SearchDailyDiaryResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "DIARY4003", description = "해당 날짜에 해당하는 다이어리를 찾을 수 없습니다.") + } + ) + ResponseEntity>> searchDailyDiary( + @Parameter( + description = "조회할 날짜 (예: 2025-07-17)" + ) + @PathVariable LocalDate date, + @AuthenticationPrincipal CustomUserDetails userDetails); + + @Operation( + summary = "월별 다이어리 조회", + description = "월별 다이어리를 조회하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "다이어리가 조회되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = DiaryResponseDTO.SearchMonthlyDiaryResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "DIARY4004", description = "해당 월에 작성된 다이어리가 없습니다.") + } + ) + ResponseEntity>> searchMonthlyDiary( + @PathVariable Integer year, + @PathVariable Integer month, + @AuthenticationPrincipal CustomUserDetails userDetails); +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/web/controller/FragranceController.java b/src/main/java/PerfumeOnMe/spring/fragrance/web/controller/FragranceController.java index d5f4826..ac69611 100644 --- a/src/main/java/PerfumeOnMe/spring/fragrance/web/controller/FragranceController.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/web/controller/FragranceController.java @@ -12,23 +12,17 @@ import PerfumeOnMe.spring.apiPayload.ApiResponse; import PerfumeOnMe.spring.fragrance.service.FragranceService; +import PerfumeOnMe.spring.fragrance.web.docs.FragranceControllerDocs; import PerfumeOnMe.spring.fragrance.web.dto.FragranceRequestDTO; import PerfumeOnMe.spring.fragrance.web.dto.FragranceResponseDTO; import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.Parameters; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor @RequestMapping("/fragrances") -@Tag(name = "Fragrance", description = "향수 CRUD API") -public class FragranceController { +public class FragranceController implements FragranceControllerDocs { private final FragranceService fragranceService; @@ -36,17 +30,6 @@ public class FragranceController { * 향수 상세 API */ @GetMapping("/allow/{fragranceId}") - @Operation( - summary = "향수 상세 조회", - description = "향수 ID로 상세 정보를 조회하는 API입니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다", content = @Content(mediaType = "application/json", schema = @Schema(implementation = FragranceResponseDTO.FragranceDetailResult.class))), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FRAGRANCE4001", description = "해당 ID에 해당하는 향수를 찾을 수 없습니다.") - } - ) - @Parameters({ - @Parameter(name = "fragranceId", description = "향수 ID"), - }) public ResponseEntity> getFragranceDetail( @PathVariable("fragranceId") Long fragranceId, @AuthenticationPrincipal CustomUserDetails userDetails) { @@ -60,19 +43,6 @@ public ResponseEntity> g * 향수 검색 API */ @GetMapping("/allow/search") - @Operation( - summary = "향수 키워드 검색", - description = "keyword 로 향수 이름을 검색하고, 페이징 처리된 결과를 반환합니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "요청에 성공하였습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = FragranceResponseDTO.FragranceSearchResult.class))), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FRAGRANCE4002", description = "검색어를 2글자 이상 입력해주세요.") - } - ) - @Parameters({ - @Parameter(name = "keyword", description = "검색어"), - @Parameter(name = "page", description = "페이지 번호"), - @Parameter(name = "size", description = "한 페이지에 불러올 향수 개수") - }) public ResponseEntity> searchFragrances( @Valid @ModelAttribute FragranceRequestDTO.FragranceSearchRequest request, @AuthenticationPrincipal CustomUserDetails userDetails @@ -86,18 +56,6 @@ public ResponseEntity> addFavorite( @PathVariable("fragranceId") Long fragranceId, @AuthenticationPrincipal CustomUserDetails userDetails) { @@ -108,18 +66,6 @@ public ResponseEntity> add // 향수 즐겨찾기 취소 API @DeleteMapping("/{fragranceId}/favorites") - @Operation( - summary = "향수 즐겨찾기 취소", - description = "향수 ID로 향수 즐겨찾기를 취소하는 API입니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "즐겨찾기에서 향수를 제거했습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = FragranceResponseDTO.FavoriteCancelResponseDTO.class))), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FAVORITES4002", description = "즐겨찾기 목록에 존재하지 않는 향수입니다."), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FRAGRANCE4001", description = "해당 ID에 해당하는 향수를 찾을 수 없습니다.") - } - ) - @Parameters({ - @Parameter(name = "fragranceId", description = "향수 ID"), - }) public ResponseEntity> deleteFavorite( @PathVariable("fragranceId") Long fragranceId, @AuthenticationPrincipal CustomUserDetails userDetails) { @@ -132,30 +78,6 @@ public ResponseEntity> searchFragrancesByFilter( @Valid @ModelAttribute FragranceRequestDTO.FragranceFilterRequest request, @AuthenticationPrincipal CustomUserDetails userDetails @@ -171,13 +93,6 @@ public ResponseEntity> getFragrancesAll( FragranceRequestDTO.FragranceAllRequest request, @AuthenticationPrincipal CustomUserDetails userDetails @@ -189,13 +104,6 @@ public ResponseEntity> getFragrancesMdChoice( @AuthenticationPrincipal CustomUserDetails userDetails) { FragranceResponseDTO.FragranceMdChoiceResult result = fragranceService.getFragranceMdChoice(userDetails); @@ -203,13 +111,6 @@ public ResponseEntity> } @GetMapping("/my-perfume") - @Operation( - summary = "메인페이지 나만의 향수 조회 API", - description = "이미지키워드나 향수공방 중 가장 최근 결과에서 추천향수를 반환하는 API입니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다", content = @Content(mediaType = "application/json", schema = @Schema(implementation = FragranceResponseDTO.FragranceMyPerfumeResult.class))), - } - ) public ResponseEntity> getFragrancesMyPerfume( @AuthenticationPrincipal CustomUserDetails userDetails) { FragranceResponseDTO.FragranceMyPerfumeResult result = fragranceService.getFragranceMyPerfume(userDetails); diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/web/docs/FragranceControllerDocs.java b/src/main/java/PerfumeOnMe/spring/fragrance/web/docs/FragranceControllerDocs.java new file mode 100644 index 0000000..7a3ca39 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/web/docs/FragranceControllerDocs.java @@ -0,0 +1,145 @@ +package PerfumeOnMe.spring.fragrance.web.docs; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; + +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.fragrance.web.dto.FragranceRequestDTO; +import PerfumeOnMe.spring.fragrance.web.dto.FragranceResponseDTO; +import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "Fragrance", description = "향수 CRUD API") +public interface FragranceControllerDocs { + + @Operation( + summary = "향수 상세 조회", + description = "향수 ID로 상세 정보를 조회하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다", content = @Content(mediaType = "application/json", schema = @Schema(implementation = FragranceResponseDTO.FragranceDetailResult.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FRAGRANCE4001", description = "해당 ID에 해당하는 향수를 찾을 수 없습니다.") + } + ) + @Parameters({ + @Parameter(name = "fragranceId", description = "향수 ID"), + }) + ResponseEntity> getFragranceDetail( + @PathVariable("fragranceId") Long fragranceId, + @AuthenticationPrincipal CustomUserDetails userDetails); + + @Operation( + summary = "향수 키워드 검색", + description = "keyword 로 향수 이름을 검색하고, 페이징 처리된 결과를 반환합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "요청에 성공하였습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = FragranceResponseDTO.FragranceSearchResult.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FRAGRANCE4002", description = "검색어를 2글자 이상 입력해주세요.") + } + ) + @Parameters({ + @Parameter(name = "keyword", description = "검색어"), + @Parameter(name = "page", description = "페이지 번호"), + @Parameter(name = "size", description = "한 페이지에 불러올 향수 개수") + }) + ResponseEntity> searchFragrances( + @Valid @ModelAttribute FragranceRequestDTO.FragranceSearchRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails); + + @Operation( + summary = "향수 즐겨찾기 등록", + description = "향수 ID로 향수 즐겨찾기를 등록하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "향수를 즐겨찾기에 등록했습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = FragranceResponseDTO.FavoriteResponseDTO.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FAVORITES4001", description = "이미 즐겨찾기에 등록한 향수입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FRAGRANCE4001", description = "해당 ID에 해당하는 향수를 찾을 수 없습니다.") + } + ) + @Parameters({ + @Parameter(name = "fragranceId", description = "향수 ID"), + }) + ResponseEntity> addFavorite( + @PathVariable("fragranceId") Long fragranceId, + @AuthenticationPrincipal CustomUserDetails userDetails); + + @Operation( + summary = "향수 즐겨찾기 취소", + description = "향수 ID로 향수 즐겨찾기를 취소하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "즐겨찾기에서 향수를 제거했습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = FragranceResponseDTO.FavoriteCancelResponseDTO.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FAVORITES4002", description = "즐겨찾기 목록에 존재하지 않는 향수입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FRAGRANCE4001", description = "해당 ID에 해당하는 향수를 찾을 수 없습니다.") + } + ) + @Parameters({ + @Parameter(name = "fragranceId", description = "향수 ID"), + }) + ResponseEntity> deleteFavorite( + @PathVariable("fragranceId") Long fragranceId, + @AuthenticationPrincipal CustomUserDetails userDetails); + + @Operation( + summary = "향수 필터링 검색 ", + description = "필터링을 통해 걸러진 향수 목록을, 페이징 처리된 결과로 반환합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "요청에 성공하였습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = FragranceResponseDTO.FragranceSearchResult.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FILTER4001", description = "유효하지 않은 성별입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FILTER4002", description = "유효하지 않은 향수 타입입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FILTER4003", description = "유효하지 않은 노트 ID 입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FILTER4004", description = "유효하지 않은 계절 ID 입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FILTER4005", description = "유효하지 않은 장소 ID 입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FILTER4006", description = "가격 범위가 올바르지 않습니다.") + } + ) + @Parameters({ + @Parameter(name = "noteCategoryId", description = "향수 카테고리(노트) ID"), + @Parameter(name = "fragranceType", description = "향수 타입 필터"), + @Parameter(name = "gender", description = "성별 필터"), + @Parameter(name = "situationId", description = "사용하는 상황 필터(Location ID)"), + @Parameter(name = "seasonId", description = "계절 필터(계절 ID)"), + @Parameter(name = "priceMin", description = "최소 가격"), + @Parameter(name = "priceMax", description = "최대 가격"), + @Parameter(name = "page", description = "페이지 번호 (0부터 시작)"), + @Parameter(name = "size", description = "한 페이지에 불러올 향수 개수") + }) + ResponseEntity> searchFragrancesByFilter( + @Valid @ModelAttribute FragranceRequestDTO.FragranceFilterRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails); + + @Operation( + summary = "향수 전체 리스트 조회", + description = "향수 전체 목록을 조회하는 API 입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다", content = @Content(mediaType = "application/json", schema = @Schema(implementation = FragranceResponseDTO.FragranceSearchResult.class))), + } + ) + ResponseEntity> getFragrancesAll( + FragranceRequestDTO.FragranceAllRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails); + + @Operation( + summary = "메인페이지 추천 향수(MD's Choice) 목록 조회 API", + description = "메인페이지에서 추천 향수(MD's Choice) 목록을 조회하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다", content = @Content(mediaType = "application/json", schema = @Schema(implementation = FragranceResponseDTO.FragranceMdChoiceResult.class))), + } + ) + ResponseEntity> getFragrancesMdChoice( + @AuthenticationPrincipal CustomUserDetails userDetails); + + @Operation( + summary = "메인페이지 나만의 향수 조회 API", + description = "이미지키워드나 향수공방 중 가장 최근 결과에서 추천향수를 반환하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다", content = @Content(mediaType = "application/json", schema = @Schema(implementation = FragranceResponseDTO.FragranceMyPerfumeResult.class))), + } + ) + ResponseEntity> getFragrancesMyPerfume( + @AuthenticationPrincipal CustomUserDetails userDetails); +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/pbti/web/controller/PbtiController.java b/src/main/java/PerfumeOnMe/spring/pbti/web/controller/PbtiController.java index bf0bed9..449db0f 100644 --- a/src/main/java/PerfumeOnMe/spring/pbti/web/controller/PbtiController.java +++ b/src/main/java/PerfumeOnMe/spring/pbti/web/controller/PbtiController.java @@ -13,33 +13,21 @@ import PerfumeOnMe.spring.apiPayload.ApiResponse; import PerfumeOnMe.spring.pbti.service.PbtiService; +import PerfumeOnMe.spring.pbti.web.docs.PbtiControllerDocs; import PerfumeOnMe.spring.pbti.web.dto.PbtiRequestDTO; import PerfumeOnMe.spring.pbti.web.dto.PbtiResponseDTO; import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor @RequestMapping("/pbti") -@Tag(name = "PBTI", description = "PBTI CRUD API") -public class PbtiController { +public class PbtiController implements PbtiControllerDocs { private final PbtiService pbtiService; // PBTI 결과 조회 API @PostMapping("/result") - @Operation( - summary = "PBTI 결과 조회", - description = "8개의 질문 선택지를 기반으로 PBTI 분석 결과를 조회합니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "PBTI 결과가 조회되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = PbtiResponseDTO.PbtiQuestionResponse.class))), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PBTI4002", description = "GPT 응답 Json 파싱 과정에서 에러가 발생했습니다.") - } - ) public ResponseEntity> searchPbti( @RequestBody PbtiRequestDTO.PbtiQuestionRequest request, @AuthenticationPrincipal CustomUserDetails userDetails) { @@ -50,14 +38,6 @@ public ResponseEntity> searchP // PBTI 결과 저장 API @PostMapping("/save") - @Operation( - summary = "PBTI 결과 저장", - description = "PBTI 분석 결과를 DB에 저장합니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "PBTI 결과가 저장되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = PbtiResponseDTO.PbtiSaveResponse.class))), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PBTI4004", description = "사용자의 PBTI 분석 결과가 Redis에서 만료되었거나 저장되어 있지 않습니다.") - } - ) public ResponseEntity> savePbti( @RequestBody PbtiRequestDTO.PbtiSaveRequest request, @AuthenticationPrincipal CustomUserDetails userDetails) { @@ -68,13 +48,6 @@ public ResponseEntity> savePbti( // 마이페이지 PBTI 목록 조회 API @GetMapping("/result/list") - @Operation( - summary = "마이페이지 PBTI 목록 조회", - description = "마이페이지에서 PBTI 목록을 조회합니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "PBTI 목록이 조회되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = PbtiResponseDTO.PbtiListResult.class))) - } - ) public ResponseEntity> searchPbtiList( @AuthenticationPrincipal CustomUserDetails userDetails) { @@ -84,14 +57,6 @@ public ResponseEntity> searc // 마이페이지 PBTI 결과 상세 조회 API @PostMapping("/detailResult") - @Operation( - summary = "마이페이지 PBTI 결과 상세 조회", - description = "마이페이지에서 PBTI 결과를 상세 조회합니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "PBTI 결과가 상세 조회되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = PbtiResponseDTO.PbtiResultDetailResponse.class))), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PBTI4005", description = "존재하지 않는 PBTI 입니다.") - } - ) public ResponseEntity> searchPbtiResult( @RequestBody PbtiRequestDTO.PbtiResultDetailRequest request, @AuthenticationPrincipal CustomUserDetails userDetails) { @@ -103,14 +68,6 @@ public ResponseEntity> sea // PBTI 결과 이름 수정 API @PatchMapping("/{pbtiId}/name") - @Operation( - summary = "PBTI 결과 이름 수정", - description = "pbtiId에 해당하는 PBTI 결과 이름을 수정합니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "PBTI 결과 이름이 수정되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = PbtiResponseDTO.UpdatePbtiNameResponse.class))), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PBTI4005", description = "존재하지 않는 PBTI 입니다.") - } - ) public ResponseEntity> updatePbtiName( @PathVariable Long pbtiId, @RequestBody PbtiRequestDTO.UpdatePbtiNameRequest request, @@ -123,14 +80,6 @@ public ResponseEntity> updat // PBTI 결과 삭제 API @DeleteMapping("/{pbtiId}/result") - @Operation( - summary = "PBTI 결과 삭제", - description = "pbtiId에 해당하는 PBTI 결과를 삭제합니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "PBTI 결과가 삭제되었습니다."), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PBTI4005", description = "존재하지 않는 PBTI 입니다.") - } - ) public ResponseEntity> deletePbtiResult( @PathVariable Long pbtiId, @AuthenticationPrincipal CustomUserDetails userDetails) { diff --git a/src/main/java/PerfumeOnMe/spring/pbti/web/docs/PbtiControllerDocs.java b/src/main/java/PerfumeOnMe/spring/pbti/web/docs/PbtiControllerDocs.java new file mode 100644 index 0000000..b11e532 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/pbti/web/docs/PbtiControllerDocs.java @@ -0,0 +1,90 @@ +package PerfumeOnMe.spring.pbti.web.docs; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.pbti.web.dto.PbtiRequestDTO; +import PerfumeOnMe.spring.pbti.web.dto.PbtiResponseDTO; +import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "PBTI", description = "PBTI CRUD API") +public interface PbtiControllerDocs { + + @Operation( + summary = "PBTI 결과 조회", + description = "8개의 질문 선택지를 기반으로 PBTI 분석 결과를 조회합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "PBTI 결과가 조회되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = PbtiResponseDTO.PbtiQuestionResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PBTI4002", description = "GPT 응답 Json 파싱 과정에서 에러가 발생했습니다.") + } + ) + ResponseEntity> searchPbti( + @RequestBody PbtiRequestDTO.PbtiQuestionRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails); + + @Operation( + summary = "PBTI 결과 저장", + description = "PBTI 분석 결과를 DB에 저장합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "PBTI 결과가 저장되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = PbtiResponseDTO.PbtiSaveResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PBTI4004", description = "사용자의 PBTI 분석 결과가 Redis에서 만료되었거나 저장되어 있지 않습니다.") + } + ) + ResponseEntity> savePbti( + @RequestBody PbtiRequestDTO.PbtiSaveRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails); + + @Operation( + summary = "마이페이지 PBTI 목록 조회", + description = "마이페이지에서 PBTI 목록을 조회합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "PBTI 목록이 조회되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = PbtiResponseDTO.PbtiListResult.class))) + } + ) + ResponseEntity> searchPbtiList( + @AuthenticationPrincipal CustomUserDetails userDetails); + + @Operation( + summary = "마이페이지 PBTI 결과 상세 조회", + description = "마이페이지에서 PBTI 결과를 상세 조회합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "PBTI 결과가 상세 조회되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = PbtiResponseDTO.PbtiResultDetailResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PBTI4005", description = "존재하지 않는 PBTI 입니다.") + } + ) + ResponseEntity> searchPbtiResult( + @RequestBody PbtiRequestDTO.PbtiResultDetailRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails); + + @Operation( + summary = "PBTI 결과 이름 수정", + description = "pbtiId에 해당하는 PBTI 결과 이름을 수정합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "PBTI 결과 이름이 수정되었습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = PbtiResponseDTO.UpdatePbtiNameResponse.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PBTI4005", description = "존재하지 않는 PBTI 입니다.") + } + ) + ResponseEntity> updatePbtiName( + @PathVariable Long pbtiId, + @RequestBody PbtiRequestDTO.UpdatePbtiNameRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails); + + @Operation( + summary = "PBTI 결과 삭제", + description = "pbtiId에 해당하는 PBTI 결과를 삭제합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "PBTI 결과가 삭제되었습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "PBTI4005", description = "존재하지 않는 PBTI 입니다.") + } + ) + ResponseEntity> deletePbtiResult( + @PathVariable Long pbtiId, + @AuthenticationPrincipal CustomUserDetails userDetails); +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/s3file/web/controller/S3Controller.java b/src/main/java/PerfumeOnMe/spring/s3file/web/controller/S3Controller.java index 922fc5e..dcd18ac 100644 --- a/src/main/java/PerfumeOnMe/spring/s3file/web/controller/S3Controller.java +++ b/src/main/java/PerfumeOnMe/spring/s3file/web/controller/S3Controller.java @@ -15,42 +15,22 @@ import PerfumeOnMe.spring.apiPayload.exception.GeneralException; import PerfumeOnMe.spring.s3file.aws.AmazonS3Manager; import PerfumeOnMe.spring.s3file.converter.S3Converter; +import PerfumeOnMe.spring.s3file.web.docs.S3ControllerDocs; import PerfumeOnMe.spring.s3file.web.dto.s3RequestDTO; import PerfumeOnMe.spring.s3file.web.dto.s3ResponseDTO; import PerfumeOnMe.spring.uuid.domain.Uuid; import PerfumeOnMe.spring.uuid.repository.UuidRepository; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; -@Tag(name = "S3 Presigned URL", description = "S3 프로필 이미지 업로드용 Presigned URL 발급 API") @RestController @RequiredArgsConstructor @RequestMapping("/s3") -public class S3Controller { +public class S3Controller implements S3ControllerDocs { private final AmazonS3Manager amazonS3Manager; private final UuidRepository uuidRepository; @PostMapping("/upload-url") - @Operation( - summary = "Presigned URL 발급", - description = "프로필 이미지 업로드를 위한 S3 Presigned URL을 생성합니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "COMMON200", - description = "Presigned URL 발급 성공", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = s3ResponseDTO.PresignedUrlResponseDTO.class)) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "S3IMAGE4001", - description = "지원하지 않은 파일 확장자입니다.", - content = @Content(mediaType = "application/json") - ) - } - ) public ResponseEntity> generatePresignedUrl( @RequestBody s3RequestDTO.PresignedUrlRequestDTO request // ✅ 요청 DTO 클래스를 요청 전용 클래스로 변경 ) { diff --git a/src/main/java/PerfumeOnMe/spring/s3file/web/docs/S3ControllerDocs.java b/src/main/java/PerfumeOnMe/spring/s3file/web/docs/S3ControllerDocs.java new file mode 100644 index 0000000..d107119 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/s3file/web/docs/S3ControllerDocs.java @@ -0,0 +1,35 @@ +package PerfumeOnMe.spring.s3file.web.docs; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestBody; + +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.s3file.web.dto.s3RequestDTO; +import PerfumeOnMe.spring.s3file.web.dto.s3ResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "S3 Presigned URL", description = "S3 프로필 이미지 업로드용 Presigned URL 발급 API") +public interface S3ControllerDocs { + + @Operation( + summary = "Presigned URL 발급", + description = "프로필 이미지 업로드를 위한 S3 Presigned URL을 생성합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON200", + description = "Presigned URL 발급 성공", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = s3ResponseDTO.PresignedUrlResponseDTO.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "S3IMAGE4001", + description = "지원하지 않은 파일 확장자입니다.", + content = @Content(mediaType = "application/json") + ) + } + ) + ResponseEntity> generatePresignedUrl( + @RequestBody s3RequestDTO.PresignedUrlRequestDTO request); +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/security/auth/controller/LoginController.java b/src/main/java/PerfumeOnMe/spring/security/auth/controller/LoginController.java index ca1b525..d3736e6 100644 --- a/src/main/java/PerfumeOnMe/spring/security/auth/controller/LoginController.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/controller/LoginController.java @@ -9,13 +9,11 @@ import org.springframework.web.bind.annotation.RestController; import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.security.auth.docs.LoginControllerDocs; import PerfumeOnMe.spring.security.auth.dto.AuthRequestDTO; import PerfumeOnMe.spring.security.auth.dto.AuthResponseDTO; import PerfumeOnMe.spring.security.auth.service.LoginService; import PerfumeOnMe.spring.user.web.dto.UserResponseDTO; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -23,19 +21,11 @@ @RestController @RequestMapping("/auth") @RequiredArgsConstructor -public class LoginController { +public class LoginController implements LoginControllerDocs { private final LoginService loginService; @PostMapping("/login") - @Operation( - summary = "로그인 API", - description = "사용자의 아이디와 비밀번호로 로그인을 진행하는 API입니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다.", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserResponseDTO.SignupResult.class))), - } - ) public ResponseEntity> login( @RequestBody @Valid AuthRequestDTO.Login loginRequest, HttpServletResponse response) throws IOException { AuthResponseDTO.LoginResult loginResult = loginService.login(loginRequest, response); diff --git a/src/main/java/PerfumeOnMe/spring/security/auth/docs/LoginControllerDocs.java b/src/main/java/PerfumeOnMe/spring/security/auth/docs/LoginControllerDocs.java new file mode 100644 index 0000000..ae849f0 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/auth/docs/LoginControllerDocs.java @@ -0,0 +1,30 @@ +package PerfumeOnMe.spring.security.auth.docs; + +import java.io.IOException; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestBody; + +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.security.auth.dto.AuthRequestDTO; +import PerfumeOnMe.spring.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.user.web.dto.UserResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; + +public interface LoginControllerDocs { + + @Operation( + summary = "로그인 API", + description = "사용자의 아이디와 비밀번호로 로그인을 진행하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다.", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserResponseDTO.SignupResult.class))), + } + ) + ResponseEntity> login( + @RequestBody @Valid AuthRequestDTO.Login loginRequest, HttpServletResponse response) throws IOException; +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/security/oauth/controller/OAuthController.java b/src/main/java/PerfumeOnMe/spring/security/oauth/controller/OAuthController.java index c2b324e..1c43e08 100644 --- a/src/main/java/PerfumeOnMe/spring/security/oauth/controller/OAuthController.java +++ b/src/main/java/PerfumeOnMe/spring/security/oauth/controller/OAuthController.java @@ -10,34 +10,21 @@ import PerfumeOnMe.spring.apiPayload.ApiResponse; import PerfumeOnMe.spring.common.enums.Social; import PerfumeOnMe.spring.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.security.oauth.docs.OAuthControllerDocs; import PerfumeOnMe.spring.security.oauth.service.OAuthService; import PerfumeOnMe.spring.security.oauth.service.OAuthServiceFactory; import PerfumeOnMe.spring.security.oauth.util.OAuthProviderResolver; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor @RequestMapping("/auth/social") -public class OAuthController { +public class OAuthController implements OAuthControllerDocs { private final OAuthServiceFactory serviceFactory; @PostMapping("/{provider}") - @Operation( - summary = "소셜 로그인 API", - description = "소셜 액세스 토큰을 발급하고, 해당 토큰으로 사용자 정보를 가져와 회원가입 및 로그인을 진행하는 API입니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다."), - }, - parameters = { - @Parameter(name = "code", description = "인가 코드가 필요합니다."), - @Parameter(name = "provider", description = "예시: kakao") - } - ) public ResponseEntity> oAuthLogin( @RequestParam("code") String code, @PathVariable("provider") String provider, diff --git a/src/main/java/PerfumeOnMe/spring/security/oauth/docs/OAuthControllerDocs.java b/src/main/java/PerfumeOnMe/spring/security/oauth/docs/OAuthControllerDocs.java new file mode 100644 index 0000000..87b3fc4 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/oauth/docs/OAuthControllerDocs.java @@ -0,0 +1,36 @@ +package PerfumeOnMe.spring.security.oauth.docs; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.security.auth.dto.AuthResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import jakarta.servlet.http.HttpServletResponse; + +public interface OAuthControllerDocs { + + @Operation( + summary = "소셜 로그인 API", + description = "소셜 액세스 토큰을 발급하고, 해당 토큰으로 사용자 정보를 가져와 회원가입 및 로그인을 진행하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다."), + }, + parameters = { + @Parameter(name = "code", description = "인가 코드가 필요합니다."), + @Parameter(name = "provider", description = "예시: kakao") + } + ) + ResponseEntity> oAuthLogin( + @RequestParam("code") String code, + @PathVariable("provider") String provider, + HttpServletResponse response); + + ResponseEntity> getCode( + @RequestParam("code") String code, + @PathVariable("provider") String Provider, + HttpServletResponse response); +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/user/web/controller/UserController.java b/src/main/java/PerfumeOnMe/spring/user/web/controller/UserController.java index bf1a691..8970a53 100644 --- a/src/main/java/PerfumeOnMe/spring/user/web/controller/UserController.java +++ b/src/main/java/PerfumeOnMe/spring/user/web/controller/UserController.java @@ -20,12 +20,9 @@ import PerfumeOnMe.spring.security.auth.dto.AuthResponseDTO; import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; import PerfumeOnMe.spring.user.service.UserService; +import PerfumeOnMe.spring.user.web.docs.UserControllerDocs; import PerfumeOnMe.spring.user.web.dto.UserRequestDTO; import PerfumeOnMe.spring.user.web.dto.UserResponseDTO; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; @@ -36,21 +33,11 @@ @RestController @RequiredArgsConstructor @RequestMapping("/users") -@Tag(name = "User", description = "사용자 CRUD API") -public class UserController { +public class UserController implements UserControllerDocs { private final UserService userService; @PostMapping("/signup") - @Operation( - summary = "자체 회원가입 API", - description = "사용자의 이름, 아이디, 비밀번호, 비밀번호 확인 값을 입력받아 회원가입하는 API입니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON201", description = "리소스를 성공적으로 생성했습니다.", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserResponseDTO.SignupResult.class))), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4001", description = "이미 사용된 아이디입니다."), - } - ) public ResponseEntity> signup( @RequestBody @Valid UserRequestDTO.Signup request) { UserResponseDTO.SignupResult result = userService.signup(request); @@ -58,15 +45,6 @@ public ResponseEntity> signup( } @PostMapping("/reissue") - @Operation( - summary = "토큰 재발급 API", - description = "헤더에 입력한 Refresh-Token으로 새로운 액세스 토큰과 리프레시 토큰을 발급하는 API입니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다."), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4002", description = "해당 리프레시 토큰이 존재하지 않습니다.") - } - ) public ResponseEntity> reissue( @RequestHeader(name = "Refresh-Token") String refreshToken, HttpServletResponse response) { AuthResponseDTO.LoginResult result = userService.reissue(refreshToken, response); @@ -74,44 +52,18 @@ public ResponseEntity> reissue( } @PostMapping("/logout") - @Operation( - summary = "로그아웃 API", - description = "사용자의 액세스 토큰과 리프레시 토큰을 블랙리스트화하는 API입니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다."), - } - ) public ResponseEntity> logout(HttpServletRequest request) { userService.logout(request); return ResponseEntity.ok().body(ApiResponse.onSuccess(null)); } @DeleteMapping("/me") - @Operation( - summary = "회원탈퇴 API", - description = "사용자의 액세스 토큰과 리프레시 토큰을 블랙리스트화하고, 사용자를 삭제하는 API입니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다."), - } - ) public ResponseEntity> deleteUser(HttpServletRequest request) { userService.deleteUser(request); return ResponseEntity.ok().body(ApiResponse.onSuccess(null)); } @PostMapping("/onboarding") - @Operation( - summary = "온보딩 API", - description = "사용자의 닉네임, 프로필 사진, 성별, 연령대, 선호하는 향 리스트를 입력받아 저장하는 API입니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다."), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4006", description = "이미 사용된 닉네임입니다."), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FILTER4003", description = "유효하지 않은 노트 ID 입니다."), - } - ) public ResponseEntity> onboarding(@Valid @RequestBody UserRequestDTO.Onboarding request, @AuthenticationPrincipal CustomUserDetails userDetails) { userService.onboarding(request, userDetails); @@ -119,15 +71,6 @@ public ResponseEntity> onboarding(@Valid @RequestBody UserRe } @PatchMapping("/me/notes") - @Operation( - summary = "선호 향 수정 API", - description = "사용자의 선호하는 향 리스트를 입력받아 수정하는 API입니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다."), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FILTER4003", description = "유효하지 않은 노트 ID 입니다."), - } - ) public ResponseEntity> updateUserNote(@Valid @RequestBody UserRequestDTO.UserNoteUpdate request, @AuthenticationPrincipal CustomUserDetails userDetails) { userService.updateUserNote(request, userDetails); @@ -135,14 +78,6 @@ public ResponseEntity> updateUserNote(@Valid @RequestBody Us } @GetMapping("/me") - @Operation( - summary = "프로필 조회 API", - description = "사용자의 프로필을 조회하는 API입니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다."), - } - ) public ResponseEntity> getUserProfile( @AuthenticationPrincipal CustomUserDetails userDetails) { Long userId = userDetails.getUserId(); @@ -151,13 +86,6 @@ public ResponseEntity> getUse } @GetMapping("/favorites") - @Operation( - summary = "즐겨찾기 목록 조회 API", - description = "사용자의 즐겨찾기 목록을 조회하는 API입니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), - } - ) public ResponseEntity> getFavoriteFragrances( @Valid @ModelAttribute FragranceRequestDTO.FragranceAllRequest request, @AuthenticationPrincipal CustomUserDetails userDetails) { @@ -168,14 +96,6 @@ public ResponseEntity> updateProfileImage( @AuthenticationPrincipal CustomUserDetails userDetails, @RequestBody @Valid UserRequestDTO.ProfileImageUpdateRequest request) { diff --git a/src/main/java/PerfumeOnMe/spring/user/web/docs/UserControllerDocs.java b/src/main/java/PerfumeOnMe/spring/user/web/docs/UserControllerDocs.java new file mode 100644 index 0000000..eb542b0 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/web/docs/UserControllerDocs.java @@ -0,0 +1,129 @@ +package PerfumeOnMe.spring.user.web.docs; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.fragrance.web.dto.FragranceRequestDTO; +import PerfumeOnMe.spring.fragrance.web.dto.FragranceResponseDTO; +import PerfumeOnMe.spring.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; +import PerfumeOnMe.spring.user.web.dto.UserRequestDTO; +import PerfumeOnMe.spring.user.web.dto.UserResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; + +@Tag(name = "User", description = "사용자 CRUD API") +public interface UserControllerDocs { + + @Operation( + summary = "자체 회원가입 API", + description = "사용자의 이름, 아이디, 비밀번호, 비밀번호 확인 값을 입력받아 회원가입하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON201", description = "리소스를 성공적으로 생성했습니다.", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserResponseDTO.SignupResult.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4001", description = "이미 사용된 아이디입니다."), + } + ) + ResponseEntity> signup( + @RequestBody @Valid UserRequestDTO.Signup request); + + @Operation( + summary = "토큰 재발급 API", + description = "헤더에 입력한 Refresh-Token으로 새로운 액세스 토큰과 리프레시 토큰을 발급하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "TOKEN4002", description = "해당 리프레시 토큰이 존재하지 않습니다.") + } + ) + ResponseEntity> reissue( + @RequestHeader(name = "Refresh-Token") String refreshToken, HttpServletResponse response); + + @Operation( + summary = "로그아웃 API", + description = "사용자의 액세스 토큰과 리프레시 토큰을 블랙리스트화하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다."), + } + ) + ResponseEntity> logout(HttpServletRequest request); + + @Operation( + summary = "회원탈퇴 API", + description = "사용자의 액세스 토큰과 리프레시 토큰을 블랙리스트화하고, 사용자를 삭제하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다."), + } + ) + ResponseEntity> deleteUser(HttpServletRequest request); + + @Operation( + summary = "온보딩 API", + description = "사용자의 닉네임, 프로필 사진, 성별, 연령대, 선호하는 향 리스트를 입력받아 저장하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4006", description = "이미 사용된 닉네임입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FILTER4003", description = "유효하지 않은 노트 ID 입니다."), + } + ) + ResponseEntity> onboarding(@Valid @RequestBody UserRequestDTO.Onboarding request, + @AuthenticationPrincipal CustomUserDetails userDetails); + + @Operation( + summary = "선호 향 수정 API", + description = "사용자의 선호하는 향 리스트를 입력받아 수정하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FILTER4003", description = "유효하지 않은 노트 ID 입니다."), + } + ) + ResponseEntity> updateUserNote(@Valid @RequestBody UserRequestDTO.UserNoteUpdate request, + @AuthenticationPrincipal CustomUserDetails userDetails); + + @Operation( + summary = "프로필 조회 API", + description = "사용자의 프로필을 조회하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다."), + } + ) + ResponseEntity> getUserProfile( + @AuthenticationPrincipal CustomUserDetails userDetails); + + @Operation( + summary = "즐겨찾기 목록 조회 API", + description = "사용자의 즐겨찾기 목록을 조회하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), + } + ) + ResponseEntity> getFavoriteFragrances( + @Valid @ModelAttribute FragranceRequestDTO.FragranceAllRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails); + + @Operation( + summary = "프로필 사진 변경 API", + description = "마이페이지에서 프로필 사진을 변경하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다.") + } + ) + ResponseEntity> updateProfileImage( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody @Valid UserRequestDTO.ProfileImageUpdateRequest request); +} \ No newline at end of file From 960d4b4bdd31df94d9ee8153437298d5787eb271 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Thu, 14 Aug 2025 22:56:51 +0900 Subject: [PATCH 325/339] =?UTF-8?q?[Fix]=20CORS=20=ED=97=88=EC=9A=A9=20Ori?= =?UTF-8?q?gin=EC=97=90=20=EB=B0=B0=ED=8F=AC=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java b/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java index c89870e..303ea61 100644 --- a/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java +++ b/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java @@ -73,7 +73,8 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(List.of("http://localhost:8080", "http://localhost:5000", "http://52.198.172.96:8080", - "http://localhost:5173")); // web 배포 주소 포함해야 함 + "http://localhost:5173", "https://frontend-git-main-jskim6335-5256s-projects.vercel.app:443", + "https://52.198.172.96:443")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); config.setAllowedHeaders(List.of("Authorization", "Refresh-Token", "Content-Type")); config.setAllowCredentials(true); From dd4082afc101ccb2ee495bdbd767aff444ed41ef Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Thu, 14 Aug 2025 23:17:35 +0900 Subject: [PATCH 326/339] =?UTF-8?q?[feature]=20workflow,=20application?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=88=98=EC=A0=95(#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev_deploy.yml | 11 ++++++++--- src/main/resources/application-dev.yml | 4 ++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index 0bff386..1ae41f3 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -61,7 +61,7 @@ jobs: run: | aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 - - name: AWS EC2 Connection + - name: AWS EC2 Connection & Deploy Spring (bind to loopback) uses: appleboy/ssh-action@v0.1.6 with: host: ${{ secrets.EC2_HOST }} @@ -70,16 +70,20 @@ jobs: port: ${{ secrets.EC2_SSH_PORT }} timeout: 500s script: | + set -e + + sudo docker network create perfume-network || true + sudo docker stop perfumeonme || true sudo docker rm perfumeonme || true sudo docker rmi chanee29/perfumeonme || true sudo docker pull chanee29/perfumeonme echo "🚀 Starting new container with the following environment variables:" - sudo docker network create perfume-network || true - sudo docker run -d -p 8080:8080 --name perfumeonme \ + sudo docker run -d --name perfumeonme \ --network perfume-network \ + -p 127.0.0.1:8080:8080 \ -e SPRING_PROFILES_ACTIVE=dev \ -e DB_URL=${{ secrets.ENV_DB_URL }} \ -e DB_USERNAME=${{ secrets.ENV_DB_USERNAME }} \ @@ -97,6 +101,7 @@ jobs: -e EXTERNAL_FASTAPI_PBTI_URL=${{ secrets.FASTAPI_PBTI_URL }} \ -e IMAGE_KEYWORD_CHARACTER_BASE_PATH=${{ secrets.IMAGE_KEYWORD_CHARACTER_BASE_PATH }} \ chanee29/perfumeonme + - name: Remove GitHub IP FROM security group run: | diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 4d3be4f..dbafb95 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -80,8 +80,8 @@ cloud: external: fastapi: - recommend-url: ${EXTERNAL_FASTAPI_RECOMMEND_URL:http://perfume-recommender-container:8000/recommend/full} # Docker 컨테이너 간 통신 - pbti-recommend-url: ${EXTERNAL_FASTAPI_PBTI_URL:http://perfume-recommender-container:8000/pbti/full-result} # Docker 컨테이너 간 통신 + recommend-url: ${EXTERNAL_FASTAPI_RECOMMEND_URL} # Docker 컨테이너 간 통신 + pbti-recommend-url: ${EXTERNAL_FASTAPI_PBTI_URL} # Docker 컨테이너 간 통신 app: image-keyword: character-image-base-path: ${IMAGE_KEYWORD_CHARACTER_BASE_PATH} From 66f01afa8bb1e76490fe4b5558a6c72ae068bc6b Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Fri, 15 Aug 2025 00:37:16 +0900 Subject: [PATCH 327/339] =?UTF-8?q?[hotfix]=20=ED=94=84=EB=A6=AC=ED=94=8C?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=ED=8A=B8=ED=97=88=ED=9A=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java b/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java index 303ea61..206faa9 100644 --- a/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java +++ b/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java @@ -4,6 +4,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -44,6 +45,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 요청 경로별 인증 확인 설정 .authorizeHttpRequests(auth -> auth + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers(AUTH_WHITELIST).permitAll() .anyRequest().authenticated() // 개발 진행할 때 임시로 풀어두기 -> 나중에 authenticated()로 변경 ) @@ -74,6 +76,7 @@ public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(List.of("http://localhost:8080", "http://localhost:5000", "http://52.198.172.96:8080", "http://localhost:5173", "https://frontend-git-main-jskim6335-5256s-projects.vercel.app:443", + "https://api.perfumeonme.p-e.kr", "https://52.198.172.96:443")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); config.setAllowedHeaders(List.of("Authorization", "Refresh-Token", "Content-Type")); From 07f1b0f429917d437dc0e4b1524132c122fbcc55 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Fri, 15 Aug 2025 01:10:39 +0900 Subject: [PATCH 328/339] =?UTF-8?q?[hotfix]=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=EC=A3=BC=EC=86=8C=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java b/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java index 206faa9..51e19e4 100644 --- a/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java +++ b/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java @@ -76,7 +76,7 @@ public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(List.of("http://localhost:8080", "http://localhost:5000", "http://52.198.172.96:8080", "http://localhost:5173", "https://frontend-git-main-jskim6335-5256s-projects.vercel.app:443", - "https://api.perfumeonme.p-e.kr", + "https://api.perfumeonme.p-e.kr", "https://perfumeonme.vercel.app", "https://52.198.172.96:443")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); config.setAllowedHeaders(List.of("Authorization", "Refresh-Token", "Content-Type")); From 138a3191b39d31c47174cb531046be6fd00adc69 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 15 Aug 2025 01:16:29 +0900 Subject: [PATCH 329/339] =?UTF-8?q?[Fix]=20=ED=96=A5=EC=88=98=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20(=ED=96=A5=EC=88=98=20=EB=B8=8C=EB=9E=9C=EB=93=9C)?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#134)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../web/docs/ChatbotControllerDocs.java | 7 +- .../repository/FragranceRepositoryImpl.java | 69 +++++++++++++++++-- .../web/docs/FragranceControllerDocs.java | 2 +- 3 files changed, 64 insertions(+), 14 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/chatbot/web/docs/ChatbotControllerDocs.java b/src/main/java/PerfumeOnMe/spring/chatbot/web/docs/ChatbotControllerDocs.java index 03e1f07..008057f 100644 --- a/src/main/java/PerfumeOnMe/spring/chatbot/web/docs/ChatbotControllerDocs.java +++ b/src/main/java/PerfumeOnMe/spring/chatbot/web/docs/ChatbotControllerDocs.java @@ -6,12 +6,10 @@ import org.springframework.web.bind.annotation.RequestBody; import PerfumeOnMe.spring.apiPayload.ApiResponse; -import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; import PerfumeOnMe.spring.chatbot.web.dto.ChatBotRequestDTO; import PerfumeOnMe.spring.chatbot.web.dto.ChatBotResponseDTO; +import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; @@ -25,9 +23,6 @@ public interface ChatbotControllerDocs { summary = "챗봇 질의 응답", description = "챗봇에게 질문을 하고 OpenAI 로부터 받은 응답을 반환하는 API 입니다." ) - @Parameters({ - @Parameter(name = "message", description = "사용자가 질문할 내용"), - }) Mono>> ask( @RequestBody ChatBotRequestDTO.ChatBotQARequest request, @AuthenticationPrincipal CustomUserDetails userDetails); diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepositoryImpl.java index 6b72cb7..8181226 100644 --- a/src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepositoryImpl.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepositoryImpl.java @@ -1,5 +1,6 @@ package PerfumeOnMe.spring.fragrance.repository; +import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -11,9 +12,9 @@ import com.querydsl.core.BooleanBuilder; import com.querydsl.core.types.dsl.BooleanExpression; -import com.querydsl.core.types.dsl.StringPath; import com.querydsl.jpa.impl.JPAQueryFactory; +import PerfumeOnMe.spring.common.enums.Brand; import PerfumeOnMe.spring.common.enums.FragranceGender; import PerfumeOnMe.spring.common.enums.FragranceType; import PerfumeOnMe.spring.fragrance.domain.Fragrance; @@ -70,11 +71,13 @@ public Optional findByIdWithAllDetails(Long id) { //향수 검색 @Override public Page findBySearchKeyword(String keyword, Pageable pageable) { + // 1) 이름 OR 브랜드 매칭 조건 생성 + BooleanExpression predicate = nameOrBrandMatches(keyword); // 실제 결과 데이터 조회 List content = queryFactory .selectFrom(f) - .where(containsKeyword(f.name, keyword)) + .where(predicate) .offset(pageable.getOffset()) // 몇 번째부터 가져올지 (page * size) .limit(pageable.getPageSize()) // 몇 개 가져올지 .fetch(); @@ -83,17 +86,13 @@ public Page findBySearchKeyword(String keyword, Pageable pageable) { Long count = queryFactory .select(f.count()) .from(f) - .where(containsKeyword(f.name, keyword)) + .where(predicate) .fetchOne(); // Page 객체 생성 return PageableExecutionUtils.getPage(content, pageable, () -> count != null ? count : 0); } - private BooleanExpression containsKeyword(StringPath field, String keyword) { - return keyword == null ? null : field.containsIgnoreCase(keyword); - } - // 향수 필터링 @Override public Page findByFilter(FragranceRequestDTO.FragranceFilterRequest r, Pageable pageable) { @@ -156,4 +155,60 @@ public Page findByFilter(FragranceRequestDTO.FragranceFilterRequest r return new PageImpl<>(result, pageable, total != null ? total : 0); } + + + /* + * 향수 검색 관련 메서드 + */ + + /** 이름 containsIgnoreCase OR 브랜드 매칭(in) */ + private BooleanExpression nameOrBrandMatches(String rawKeyword) { + if (isBlank(rawKeyword)) + return null; + + // 앞뒤 공백 제거 + String keyword = rawKeyword.trim(); + + // 이름 containsIgnoreCase + BooleanExpression byName = f.name.containsIgnoreCase(keyword); + + // 브랜드 enum 후보들 선별 (영문 enum 명 / 한글표기 모두 부분일치) + List matchedBrands = findMatchingBrands(keyword); + + // 브랜드 조건 (후보가 없을 수도 있음) + BooleanExpression byBrand = matchedBrands.isEmpty() ? null : f.brand.in(matchedBrands); + + // 이름 또는 브랜드 + return or(byName, byBrand); + } + + private List findMatchingBrands(String keyword) { + // 비교 일관성을 위해 키워드를 소문자로 통일 + String kw = keyword.toLowerCase(); + List result = new ArrayList<>(); + + // 모든 브랜드 enum 상수를 순회. + for (Brand b : Brand.values()) { + // enum 상수명(영문) 또는 showBrand(한/영 혼합 표시)에 부분일치 + String name = b.name().toLowerCase(); + String show = b.getShowBrand() == null ? "" : b.getShowBrand().toLowerCase(); + if (name.contains(kw) || show.contains(kw)) { + result.add(b); + } + } + return result; + } + + /** 유틸: OR 결합 (null 안전) */ + private BooleanExpression or(BooleanExpression a, BooleanExpression b) { + if (a == null) + return b; + if (b == null) + return a; + return a.or(b); + } + + private boolean isBlank(String s) { + return s == null || s.trim().isEmpty(); + } } diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/web/docs/FragranceControllerDocs.java b/src/main/java/PerfumeOnMe/spring/fragrance/web/docs/FragranceControllerDocs.java index 7a3ca39..c061774 100644 --- a/src/main/java/PerfumeOnMe/spring/fragrance/web/docs/FragranceControllerDocs.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/web/docs/FragranceControllerDocs.java @@ -37,7 +37,7 @@ ResponseEntity> getFragr @Operation( summary = "향수 키워드 검색", - description = "keyword 로 향수 이름을 검색하고, 페이징 처리된 결과를 반환합니다.", + description = "keyword 로 '향수이름' 또는 '브랜드'를 검색하고, 페이징 처리된 결과를 반환합니다.", responses = { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "요청에 성공하였습니다.", content = @Content(mediaType = "application/json", schema = @Schema(implementation = FragranceResponseDTO.FragranceSearchResult.class))), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "FRAGRANCE4002", description = "검색어를 2글자 이상 입력해주세요.") From 67ef3c73ba2aeecc469fb46846ee030eeec3dc73 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Fri, 15 Aug 2025 01:24:53 +0900 Subject: [PATCH 330/339] =?UTF-8?q?[hotfix]=20=EC=98=A4=EB=A5=98=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=EC=9B=90=EC=9D=B8=EC=B0=BE=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java b/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java index 51e19e4..477eb13 100644 --- a/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java +++ b/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java @@ -4,7 +4,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -45,7 +44,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 요청 경로별 인증 확인 설정 .authorizeHttpRequests(auth -> auth - .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + // .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers(AUTH_WHITELIST).permitAll() .anyRequest().authenticated() // 개발 진행할 때 임시로 풀어두기 -> 나중에 authenticated()로 변경 ) From 5d2ca6574e54e896dea6f7aca7fc332e31d77ac0 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Fri, 15 Aug 2025 01:26:49 +0900 Subject: [PATCH 331/339] =?UTF-8?q?ip=EC=A3=BC=EC=86=8C=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=91=EA=B7=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java b/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java index 477eb13..c10becf 100644 --- a/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java +++ b/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java @@ -75,8 +75,7 @@ public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(List.of("http://localhost:8080", "http://localhost:5000", "http://52.198.172.96:8080", "http://localhost:5173", "https://frontend-git-main-jskim6335-5256s-projects.vercel.app:443", - "https://api.perfumeonme.p-e.kr", "https://perfumeonme.vercel.app", - "https://52.198.172.96:443")); + "https://api.perfumeonme.p-e.kr", "https://perfumeonme.vercel.app")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); config.setAllowedHeaders(List.of("Authorization", "Refresh-Token", "Content-Type")); config.setAllowCredentials(true); From 344c0eb25782534e6303f97bd5897ea668fbff44 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Fri, 15 Aug 2025 01:30:08 +0900 Subject: [PATCH 332/339] =?UTF-8?q?[Hotfix]=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EC=A3=BC=EC=86=8C=20=EC=A0=95=EC=83=81?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java b/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java index c10becf..e771afe 100644 --- a/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java +++ b/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java @@ -44,7 +44,6 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 요청 경로별 인증 확인 설정 .authorizeHttpRequests(auth -> auth - // .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers(AUTH_WHITELIST).permitAll() .anyRequest().authenticated() // 개발 진행할 때 임시로 풀어두기 -> 나중에 authenticated()로 변경 ) @@ -74,7 +73,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(List.of("http://localhost:8080", "http://localhost:5000", "http://52.198.172.96:8080", - "http://localhost:5173", "https://frontend-git-main-jskim6335-5256s-projects.vercel.app:443", + "http://localhost:5173", "https://api.perfumeonme.p-e.kr", "https://perfumeonme.vercel.app")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); config.setAllowedHeaders(List.of("Authorization", "Refresh-Token", "Content-Type")); From 3a9b7310fa659d51f66fc4ca3c7a112ecc264dc2 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 15 Aug 2025 01:49:05 +0900 Subject: [PATCH 333/339] =?UTF-8?q?[Fix]=20=ED=9D=90=EB=A6=84=20=ED=8C=8C?= =?UTF-8?q?=EC=95=85=EC=9D=84=20=EC=9C=84=ED=95=B4=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD=20(#134)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/FragranceRepositoryImpl.java | 110 +++++++++--------- 1 file changed, 55 insertions(+), 55 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepositoryImpl.java index 8181226..fb5026e 100644 --- a/src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepositoryImpl.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepositoryImpl.java @@ -93,6 +93,61 @@ public Page findBySearchKeyword(String keyword, Pageable pageable) { return PageableExecutionUtils.getPage(content, pageable, () -> count != null ? count : 0); } + /* + * 향수 검색 관련 메서드 + */ + + /** 이름 containsIgnoreCase OR 브랜드 매칭(in) */ + private BooleanExpression nameOrBrandMatches(String rawKeyword) { + if (isBlank(rawKeyword)) + return null; + + // 앞뒤 공백 제거 + String keyword = rawKeyword.trim(); + + // 이름 containsIgnoreCase + BooleanExpression byName = f.name.containsIgnoreCase(keyword); + + // 브랜드 enum 후보들 선별 (영문 enum 명 / 한글표기 모두 부분일치) + List matchedBrands = findMatchingBrands(keyword); + + // 브랜드 조건 (후보가 없을 수도 있음) + BooleanExpression byBrand = matchedBrands.isEmpty() ? null : f.brand.in(matchedBrands); + + // 이름 또는 브랜드 + return or(byName, byBrand); + } + + private List findMatchingBrands(String keyword) { + // 비교 일관성을 위해 키워드를 소문자로 통일 + String kw = keyword.toLowerCase(); + List result = new ArrayList<>(); + + // 모든 브랜드 enum 상수를 순회. + for (Brand b : Brand.values()) { + // enum 상수명(영문) 또는 showBrand(한/영 혼합 표시)에 부분일치 + String name = b.name().toLowerCase(); + String show = b.getShowBrand() == null ? "" : b.getShowBrand().toLowerCase(); + if (name.contains(kw) || show.contains(kw)) { + result.add(b); + } + } + return result; + } + + /** 유틸: OR 결합 (null 안전) */ + private BooleanExpression or(BooleanExpression a, BooleanExpression b) { + if (a == null) + return b; + if (b == null) + return a; + return a.or(b); + } + + private boolean isBlank(String s) { + return s == null || s.trim().isEmpty(); + } + // 향수 필터링 @Override public Page findByFilter(FragranceRequestDTO.FragranceFilterRequest r, Pageable pageable) { @@ -156,59 +211,4 @@ public Page findByFilter(FragranceRequestDTO.FragranceFilterRequest r return new PageImpl<>(result, pageable, total != null ? total : 0); } - - /* - * 향수 검색 관련 메서드 - */ - - /** 이름 containsIgnoreCase OR 브랜드 매칭(in) */ - private BooleanExpression nameOrBrandMatches(String rawKeyword) { - if (isBlank(rawKeyword)) - return null; - - // 앞뒤 공백 제거 - String keyword = rawKeyword.trim(); - - // 이름 containsIgnoreCase - BooleanExpression byName = f.name.containsIgnoreCase(keyword); - - // 브랜드 enum 후보들 선별 (영문 enum 명 / 한글표기 모두 부분일치) - List matchedBrands = findMatchingBrands(keyword); - - // 브랜드 조건 (후보가 없을 수도 있음) - BooleanExpression byBrand = matchedBrands.isEmpty() ? null : f.brand.in(matchedBrands); - - // 이름 또는 브랜드 - return or(byName, byBrand); - } - - private List findMatchingBrands(String keyword) { - // 비교 일관성을 위해 키워드를 소문자로 통일 - String kw = keyword.toLowerCase(); - List result = new ArrayList<>(); - - // 모든 브랜드 enum 상수를 순회. - for (Brand b : Brand.values()) { - // enum 상수명(영문) 또는 showBrand(한/영 혼합 표시)에 부분일치 - String name = b.name().toLowerCase(); - String show = b.getShowBrand() == null ? "" : b.getShowBrand().toLowerCase(); - if (name.contains(kw) || show.contains(kw)) { - result.add(b); - } - } - return result; - } - - /** 유틸: OR 결합 (null 안전) */ - private BooleanExpression or(BooleanExpression a, BooleanExpression b) { - if (a == null) - return b; - if (b == null) - return a; - return a.or(b); - } - - private boolean isBlank(String s) { - return s == null || s.trim().isEmpty(); - } } From 59f2a5bc036e339b1a234425370f9de7721b46ea Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 15 Aug 2025 13:09:18 +0900 Subject: [PATCH 334/339] =?UTF-8?q?[Fix]=20Brand=20enum=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=B6=94=EA=B0=80=20(#115)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/common/enums/Brand.java | 20 +++++++++++++++++- .../fragranceInit/FragranceRowProcessor.java | 21 +++++++++++++++++-- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/common/enums/Brand.java b/src/main/java/PerfumeOnMe/spring/common/enums/Brand.java index f4ece08..93437a0 100644 --- a/src/main/java/PerfumeOnMe/spring/common/enums/Brand.java +++ b/src/main/java/PerfumeOnMe/spring/common/enums/Brand.java @@ -17,7 +17,25 @@ public enum Brand { YVES_SAINT_LAURENT("입생로랑 (YVES SAINT LAURENT)"), LE_LABO("르 라보 (LE LABO)"), VERSACE("베르사체 (VERSACE)"), - MAISON_FRANCIS_KURKDJIAN("메종 프란시스 커정 (MAISON FRANCIS_KURKDJIAN)"); + MAISON_FRANCIS_KURKDJIAN("메종 프란시스 커정 (MAISON FRANCIS KURKDJIAN)"), + PRADA("프라다 (PRADA)"), + LOUIS_VUITTON("루이비통 (LOUIS VUITTON)"), + GIVENCHY("지방시 (GIVENCHY)"), + CREED("크리드 (CREED)"), + BDK("bdk (BDK PARFUMES)"), + SERGE_LUTENS("세르주 루텐 (SERGE LUTENS)"), + NISHANE("니샤네 (NISHANE)"), + GIORGIO_ARMANI("조르지오 아르마니 (GIORGIO ARMANI)"), + KILIAN("킬리안 (KILIAN)"), + ETAT_LIBRE_DORANGE("에따 리브르 도랑쥬 (ETAT LIBRE D'ORANGE)"), + XERIOFF("제르조프 (XERIOFF)"), + ACQUA_DI_PARMA("아쿠아 디 파르마 (ACQUA DI PARMA)"), + HERMES("에르메스 (HERMES)"), + DIOR("디올 (DIOR)"), + GUERLAIN("겔랑 (GUERLAIN)"), + L_ART_ET_LA_MATIERE_BY_GUERLAIN("겔랑 라르 & 라 마티에르 (L ART ET LA MATIERE BY GUERLAIN)"), + CHANEL("샤넬 (CHANEL)"), + LES_EXCLUSIFS_DE_CHANEL("샤넬 익스클루시브 (LES EXCLUSIFS DE CHANEL)"); private final String showBrand; } diff --git a/src/main/java/PerfumeOnMe/spring/common/fragranceInit/FragranceRowProcessor.java b/src/main/java/PerfumeOnMe/spring/common/fragranceInit/FragranceRowProcessor.java index 5a699cb..a240cc1 100644 --- a/src/main/java/PerfumeOnMe/spring/common/fragranceInit/FragranceRowProcessor.java +++ b/src/main/java/PerfumeOnMe/spring/common/fragranceInit/FragranceRowProcessor.java @@ -216,8 +216,25 @@ private Brand convertToBrand(String brandStr) { case "YVES SAINT LAURENT" -> Brand.YVES_SAINT_LAURENT; case "LE LABO" -> Brand.LE_LABO; case "VERSACE" -> Brand.VERSACE; - case "메종 프란시스 커정" -> Brand.MAISON_FRANCIS_KURKDJIAN; - + case "MAISON FRANCIS KURKDJIAN" -> Brand.MAISON_FRANCIS_KURKDJIAN; + case "PRADA" -> Brand.PRADA; + case "LOUIS VUITTON" -> Brand.LOUIS_VUITTON; + case "CREED" -> Brand.CREED; + case "GIVENCHY" -> Brand.GIVENCHY; + case "BDK" -> Brand.BDK; + case "SERGE LUTENS" -> Brand.SERGE_LUTENS; + case "NISHANE" -> Brand.NISHANE; + case "GIORGIO ARMANI" -> Brand.GIORGIO_ARMANI; + case "KILIAN" -> Brand.KILIAN; + case "ETAT LIBRE DORANGE" -> Brand.ETAT_LIBRE_DORANGE; + case "XERIOFF" -> Brand.XERIOFF; + case "ACQUA DI PARMA" -> Brand.ACQUA_DI_PARMA; + case "HERMES" -> Brand.HERMES; + case "DIOR" -> Brand.DIOR; + case "GUERLAIN" -> Brand.GUERLAIN; + case "L ART ET LA MATIÈRE BY GUERLAIN" -> Brand.L_ART_ET_LA_MATIERE_BY_GUERLAIN; + case "CHANEL" -> Brand.CHANEL; + case "LES EXCLUSIFS DE CHANEL" -> Brand.LES_EXCLUSIFS_DE_CHANEL; default -> throw new GeneralException(ErrorStatus.UNSUPPORTED_BRAND, "지원하지 않는 브랜드: " + brandStr); }; } From 77376a0c5ecd6e20c50e3bdf92015cb8c209d928 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 15 Aug 2025 13:44:35 +0900 Subject: [PATCH 335/339] =?UTF-8?q?[Docs]=20=EC=B1=97=EB=B4=87=20=ED=94=84?= =?UTF-8?q?=EB=A1=AC=ED=94=84=ED=8A=B8=20=EC=88=98=EC=A0=95=20(#149)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/prompts/expert.txt | 57 ++++++++++++--------------- 1 file changed, 25 insertions(+), 32 deletions(-) diff --git a/src/main/resources/prompts/expert.txt b/src/main/resources/prompts/expert.txt index 2eeef6d..821b410 100644 --- a/src/main/resources/prompts/expert.txt +++ b/src/main/resources/prompts/expert.txt @@ -1,42 +1,35 @@ -⚙️ 시스템 메시지 +너는 향수 추천 플랫폼의 챗봇이야. +사용자가 말한 사물이나 장면을 보고 그 느낌에 어울리는 향수를 3가지 추천해줘. +말투는 친근하고 짧게, 친구랑 얘기하듯이 해. 존댓말은 유지하지만 설명은 부담 없이. -너는 향수 추천 플랫폼의 챗봇이야. -사용자가 말한 사물이나 장면을 통해 연상되는 향기를 이해하고, -그에 어울리는 가장 유사한 향수를 3가지 추천해줘. -말투는 딱딱하지 않게. 친한 친구처럼. 반말도 섞어가면서 아주 친근하게 해줘. +각 향수는 아래 형식을 반드시 지키고, 줄바꿈과 들여쓰기를 그대로 출력할 것. +비유 설명은 사용자가 말한 느낌을 1문장, 15단어 이내로 간단히. -각 향수는 다음 정보를 포함해야 해: +출력 형식 (항상 동일하게): -- 이름 -- 브랜드 -- 주요 향 노트 (예: 시트러스, 머스크 등) -- 사용자 입력에 대한 감각적 비유 설명 (2문장 이내, 간결하고 친근한 말투) +1. Perfume 1 + 🌸 이름: <향수 이름> + 🏷 브랜드: <브랜드> + 🪵 주요 향 노트: <노트> + 💬 비유 설명: "<사용자 입력> 같은 느낌에 어울리는 …" -예시 입력: “지하주차장 냄새 같은 향수 알려줘” -출력 형식 (이 형식을 절대 벗어나지 마) +2. Perfume 2 + 🌸 이름: <향수 이름> + 🏷 브랜드: <브랜드> + 🪵 주요 향 노트: <노트> + 💬 비유 설명: "<사용자 입력> 같은 느낌에 어울리는 …" -1. Perfume 1 - - 이름: `<향수 이름>` - - 브랜드: `<브랜드>` - - 주요 향 노트: `<노트>` - - 비유 설명: "`<사용자 입력>`처럼 …" -2. Perfume 2 - - 이름: - - 브랜드: - - 주요 향 노트: - - 비유 설명: +3. Perfume 3 + 🌸 이름: <향수 이름> + 🏷 브랜드: <브랜드> + 🪵 주요 향 노트: <노트> + 💬 비유 설명: "<사용자 입력> 같은 느낌에 어울리는 …" -3. Perfume 3 - - 이름: - - 브랜드: - - 주요 향 노트: - - 비유 설명: 주의 사항 -1. **반드시 향수는 3개 추천할 것** -2. **지정된 출력 형식만 사용하고, 그 외 설명은 절대 하지 마** -3. **말투는 친근하고 짧게 해줘. 그대신 야 라고 부르진 말고 존댓말 써줘"** - - +1. 항상 3개 추천 +2. 형식과 들여쓰기·이모지·구분선은 예시와 동일하게 출력 +3. 비유 설명은 짧고 직관적으로, 불필요한 디테일 X +4. 항목 앞의 이모지와 공백 개수도 반드시 지킬 것 From 9c12e06920ddde6de1ae080b49777d7c2058cc47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EC=84=AD?= <113495894+jodandan@users.noreply.github.com> Date: Fri, 15 Aug 2025 19:47:49 +0900 Subject: [PATCH 336/339] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cf19be4..f28eddc 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ GPT 기반 분석, 키워드 검색, 설문 등 다양한 방법을 통해 사 ## 🔗 배포 주소 -> [🌐 퍼퓨온미 바로가기](https://perfuonme.example.com) +> [🌐 퍼퓨온미 바로가기](https://perfumeonme.vercel.app) --- From bdb3e375784ef076646d3be01b7edeef4e3c4b90 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Sat, 16 Aug 2025 00:54:12 +0900 Subject: [PATCH 337/339] =?UTF-8?q?[Fix]=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20D?= =?UTF-8?q?TO=20=EC=9E=85=EB=A0=A5=20=EC=98=88=EC=8B=9C=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20(#153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PerfumeOnMe/spring/security/auth/dto/AuthRequestDTO.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/security/auth/dto/AuthRequestDTO.java b/src/main/java/PerfumeOnMe/spring/security/auth/dto/AuthRequestDTO.java index d774997..6d962db 100644 --- a/src/main/java/PerfumeOnMe/spring/security/auth/dto/AuthRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/dto/AuthRequestDTO.java @@ -1,5 +1,6 @@ package PerfumeOnMe.spring.security.auth.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import lombok.Getter; import lombok.NoArgsConstructor; @@ -10,8 +11,10 @@ public class AuthRequestDTO { @NoArgsConstructor public static class Login { @NotNull + @Schema(description = "사용자가 입력한 아이디", example = "umc123") private String loginId; @NotNull + @Schema(description = "사용자가 입력한 비밀번호", example = "asdf1234") private String password; } } From de0270c3b59d369444ea4a293f600e37c3cdc326 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Sat, 16 Aug 2025 00:55:18 +0900 Subject: [PATCH 338/339] =?UTF-8?q?[Docs]=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=EC=9D=B4=EB=A6=84=20=ED=91=9C=EC=8B=9C=20&=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20DTO=20=EB=B3=80=EA=B2=BD=20&=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EC=B6=94=EA=B0=80=20(#153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/security/auth/docs/LoginControllerDocs.java | 6 ++++-- .../spring/security/oauth/docs/OAuthControllerDocs.java | 7 ++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/security/auth/docs/LoginControllerDocs.java b/src/main/java/PerfumeOnMe/spring/security/auth/docs/LoginControllerDocs.java index ae849f0..24506b5 100644 --- a/src/main/java/PerfumeOnMe/spring/security/auth/docs/LoginControllerDocs.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/docs/LoginControllerDocs.java @@ -8,13 +8,14 @@ import PerfumeOnMe.spring.apiPayload.ApiResponse; import PerfumeOnMe.spring.security.auth.dto.AuthRequestDTO; import PerfumeOnMe.spring.security.auth.dto.AuthResponseDTO; -import PerfumeOnMe.spring.user.web.dto.UserResponseDTO; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; +@Tag(name = "Auth Login", description = "일반 로그인 API") public interface LoginControllerDocs { @Operation( @@ -22,7 +23,8 @@ public interface LoginControllerDocs { description = "사용자의 아이디와 비밀번호로 로그인을 진행하는 API입니다.", responses = { @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다.", - content = @Content(mediaType = "application/json", schema = @Schema(implementation = UserResponseDTO.SignupResult.class))), + content = @Content(mediaType = "application/json", schema = @Schema(implementation = AuthResponseDTO.LoginResult.class))), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다."), } ) ResponseEntity> login( diff --git a/src/main/java/PerfumeOnMe/spring/security/oauth/docs/OAuthControllerDocs.java b/src/main/java/PerfumeOnMe/spring/security/oauth/docs/OAuthControllerDocs.java index 87b3fc4..1d7ea22 100644 --- a/src/main/java/PerfumeOnMe/spring/security/oauth/docs/OAuthControllerDocs.java +++ b/src/main/java/PerfumeOnMe/spring/security/oauth/docs/OAuthControllerDocs.java @@ -8,15 +8,20 @@ import PerfumeOnMe.spring.security.auth.dto.AuthResponseDTO; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletResponse; +@Tag(name = "OAuth Login", description = "소셜 로그인 API") public interface OAuthControllerDocs { @Operation( summary = "소셜 로그인 API", description = "소셜 액세스 토큰을 발급하고, 해당 토큰으로 사용자 정보를 가져와 회원가입 및 로그인을 진행하는 API입니다.", responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다.", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = AuthResponseDTO.LoginResult.class))), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "MEMBER4003", description = "해당 아이디를 가진 사용자가 존재하지 않습니다."), }, parameters = { From 5f2d224f5a9e04817057aa51cbe4f1bc4efe03fd Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Sat, 16 Aug 2025 00:56:05 +0900 Subject: [PATCH 339/339] =?UTF-8?q?[Fix]=20=EC=9D=BC=EB=B0=98/=EC=86=8C?= =?UTF-8?q?=EC=85=9C=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=EC=97=90=20name=20=EC=B6=94=EA=B0=80=20(#153)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/security/auth/converter/AuthConverter.java | 4 +++- .../PerfumeOnMe/spring/security/auth/dto/AuthResponseDTO.java | 1 + .../spring/security/auth/filter/JwtLoginFilter.java | 4 +++- .../spring/security/auth/service/LoginServiceImpl.java | 3 ++- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/security/auth/converter/AuthConverter.java b/src/main/java/PerfumeOnMe/spring/security/auth/converter/AuthConverter.java index 32a1332..e993a1d 100644 --- a/src/main/java/PerfumeOnMe/spring/security/auth/converter/AuthConverter.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/converter/AuthConverter.java @@ -5,11 +5,13 @@ public class AuthConverter { - public static AuthResponseDTO.LoginResult toLoginResult(String refreshToken, Long userId, Social social) { + public static AuthResponseDTO.LoginResult toLoginResult(String refreshToken, Long userId, Social social, + String name) { return AuthResponseDTO.LoginResult.builder() .refreshToken(refreshToken) .userId(userId) .social(social) + .name(name) .build(); } diff --git a/src/main/java/PerfumeOnMe/spring/security/auth/dto/AuthResponseDTO.java b/src/main/java/PerfumeOnMe/spring/security/auth/dto/AuthResponseDTO.java index 3c30dd4..8c48dcc 100644 --- a/src/main/java/PerfumeOnMe/spring/security/auth/dto/AuthResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/dto/AuthResponseDTO.java @@ -16,5 +16,6 @@ public static class LoginResult { private String refreshToken; private Long userId; private Social social; + private String name; } } diff --git a/src/main/java/PerfumeOnMe/spring/security/auth/filter/JwtLoginFilter.java b/src/main/java/PerfumeOnMe/spring/security/auth/filter/JwtLoginFilter.java index b0435db..1304350 100644 --- a/src/main/java/PerfumeOnMe/spring/security/auth/filter/JwtLoginFilter.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/filter/JwtLoginFilter.java @@ -78,6 +78,7 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR // Authentication에서 principal String 추출 String loginId = authResult.getName(); Long userId = ((CustomUserDetails)authResult.getPrincipal()).getUserId(); + String name = ((CustomUserDetails)authResult.getPrincipal()).getName(); // 사용자의 로그아웃 액세스 토큰이 존재하는 경우 삭제 if (logoutAccessTokenManager.findLogoutAccessToken(loginId)) { @@ -87,7 +88,8 @@ protected void successfulAuthentication(HttpServletRequest request, HttpServletR // 토큰 생성 및 DTO에 담기 String accessToken = jwtTokenProvider.createAccessToken(authResult); String refreshToken = jwtTokenProvider.createRefreshToken(authResult); - AuthResponseDTO.LoginResult loginResultDTO = AuthConverter.toLoginResult(refreshToken, userId, Social.LOCAL); + AuthResponseDTO.LoginResult loginResultDTO = AuthConverter.toLoginResult(refreshToken, userId, Social.LOCAL, + name); // 리프레시 토큰을 Redis에 저장 refreshTokenManager.saveRefreshToken(loginId, refreshToken); diff --git a/src/main/java/PerfumeOnMe/spring/security/auth/service/LoginServiceImpl.java b/src/main/java/PerfumeOnMe/spring/security/auth/service/LoginServiceImpl.java index 7d9c2f3..839719a 100644 --- a/src/main/java/PerfumeOnMe/spring/security/auth/service/LoginServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/service/LoginServiceImpl.java @@ -71,6 +71,7 @@ public AuthResponseDTO.LoginResult generateAuthResponse(String loginId, // Authentication에서 userId 추출 Long userId = ((CustomUserDetails)request.getPrincipal()).getUserId(); + String name = ((CustomUserDetails)request.getPrincipal()).getName(); // 토큰 생성 및 DTO에 담기 String accessToken = jwtTokenProvider.createAccessToken(request); @@ -85,6 +86,6 @@ public AuthResponseDTO.LoginResult generateAuthResponse(String loginId, response.setStatus(HttpServletResponse.SC_OK); response.setHeader("Authorization", "Bearer " + accessToken); - return AuthConverter.toLoginResult(refreshToken, userId, social); + return AuthConverter.toLoginResult(refreshToken, userId, social, name); } }

*SgPXREO#ZIC$G&Povn4 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 literal 124531 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 Date: Wed, 30 Jul 2025 16:59:47 +0900 Subject: [PATCH 284/339] datasheet commit --- src/main/resources/prompts/expert.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/prompts/expert.txt b/src/main/resources/prompts/expert.txt index 916ad36..413a12e 100644 --- a/src/main/resources/prompts/expert.txt +++ b/src/main/resources/prompts/expert.txt @@ -37,5 +37,5 @@ 주의 사항 1. **반드시 향수는 3개 추천할 것** 2. **지정된 출력 형식만 사용하고, 그 외 설명은 절대 하지 마** -3. **말투는 친근하고 짧게 해줘. 그대신 "야" "** +3. **말투는 친근하고 짧게 해줘. 그대신 야 라고 부르진 말고 적당히 존댓말 써줘"** From 6a55682a1ebaa9db2b34c97e40a00ad26508007f Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Wed, 30 Jul 2025 17:16:40 +0900 Subject: [PATCH 285/339] update -> create --- src/main/resources/application-dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index c13adcf..dc75a9b 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -12,7 +12,7 @@ spring: mode: never jpa: hibernate: - ddl-auto: update # create X + ddl-auto: create # create X properties: hibernate: dialect: org.hibernate.dialect.MySQL8Dialect From 483b060beee9b832ef422c60aaab55c7fa26a83c Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Wed, 30 Jul 2025 17:58:26 +0900 Subject: [PATCH 286/339] =?UTF-8?q?[refactor]=20WorkshopController,=20Imag?= =?UTF-8?q?eKeywordController=20Swagger=20=EC=96=B4=EB=85=B8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EB=B6=84=EB=A6=AC=20(#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ImageKeywordController.java | 63 +------- .../web/controller/WorkshopController.java | 106 +------------ .../ImageKeywordControllerDocs.java | 96 ++++++++++++ .../docs/workshop/WorkshopControllerDocs.java | 139 ++++++++++++++++++ 4 files changed, 239 insertions(+), 165 deletions(-) create mode 100644 src/main/java/PerfumeOnMe/spring/web/docs/imagekeyword/ImageKeywordControllerDocs.java create mode 100644 src/main/java/PerfumeOnMe/spring/web/docs/workshop/WorkshopControllerDocs.java diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java b/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java index fe07410..b4eeaf9 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java @@ -16,45 +16,21 @@ import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; import PerfumeOnMe.spring.service.imagekeyword.ImageKeywordPreviewService; import PerfumeOnMe.spring.service.imagekeyword.ImageKeywordService; +import PerfumeOnMe.spring.web.docs.imagekeyword.ImageKeywordControllerDocs; import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordRequestDTO; import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordResponseDTO; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor @RequestMapping("/image-keyword") -@Tag(name = "Image-Keyword", description = "이미지키워드 API") -public class ImageKeywordController { +public class ImageKeywordController implements ImageKeywordControllerDocs { private final ImageKeywordService imageKeywordService; private final ImageKeywordPreviewService previewService; // 이미지키워드 목록 조회 API @GetMapping("/result/list") - @Operation( - summary = "이미지 키워드 목록 조회 (마이페이지)", - description = "해당 유저가 저장한 이미지 키워드 결과 목록을 조회합니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "COMMON200", - description = "요청에 성공하였습니다.", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = ImageKeywordResponseDTO.ImageKeywordListResponseDTO.class) - ) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "COMMON401", - description = "인증이 필요합니다. 액세스 토큰을 입력해주세요." - ) - } - ) public ResponseEntity>> getImageKeywordList( - @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails ) { List result = imageKeywordService.getImageKeywordList( @@ -64,14 +40,6 @@ public ResponseEntity> getImageKeywordDetail( @PathVariable Long imageKeywordId, @AuthenticationPrincipal CustomUserDetails userDetails @@ -83,17 +51,7 @@ public ResponseEntity> getImageKeywordPreview( - @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails, @Validated @RequestBody ImageKeywordRequestDTO.ImageKeywordPreviewRequestDTO request ) { @@ -103,24 +61,7 @@ public ResponseEntity> saveImageKeywordResult( - @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails, @Validated @RequestBody ImageKeywordRequestDTO.ImageKeywordSaveRequestDTO request ) { diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java b/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java index 9052d14..d6eeb19 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java @@ -14,53 +14,23 @@ import PerfumeOnMe.spring.apiPayload.ApiResponse; import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; import PerfumeOnMe.spring.service.workshop.WorkshopService; +import PerfumeOnMe.spring.web.docs.workshop.WorkshopControllerDocs; import PerfumeOnMe.spring.web.dto.workshop.WorkshopRequestDTO; import PerfumeOnMe.spring.web.dto.workshop.WorkshopResponseDTO; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor @RequestMapping("/workshop") -@Tag(name = "Workshop", description = "향수공방 API") -public class WorkshopController { +public class WorkshopController implements WorkshopControllerDocs { private final WorkshopService workshopService; /** 향수공방 결과 미리보기(결과 생성)*/ @PostMapping("/preview") - @Operation( - summary = "향수공방 결과 확인(미리보기)", - description = "사용자가 선택한 향(Top, Middle, Base 노트)과 용량을 바탕으로 향기 해석 결과를 미리 확인합니다. " + - "결과는 Redis에 15분간 임시 저장되며, 향수공방 저장 API 호출 시 활용됩니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "COMMON200", - description = "요청에 성공하였습니다.", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = WorkshopResponseDTO.WorkshopPreviewResponseDTO.class) - ) - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "COMMON401", - description = "인증이 필요합니다. 액세스 토큰을 입력해주세요." - ), - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "WORKSHOP4002", - description = "선택한 노트들의 총 용량은 10을 초과할 수 없습니다." - ) - } - ) public ResponseEntity> getWorkshopPreview( - @Parameter(description = "향수공방 미리보기 생성 요청", required = true) @RequestBody @Valid WorkshopRequestDTO.WorkshopPreviewRequestDTO request, - @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails ) { return ResponseEntity.ok(ApiResponse.onSuccess(workshopService. @@ -69,37 +39,8 @@ public ResponseEntity> saveWorkshopResult( - @Parameter(description = "향수공방 저장 요청", required = true) @RequestBody @Valid WorkshopRequestDTO.WorkshopSaveRequestDTO request, - @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails ) { return ResponseEntity.ok(ApiResponse.onSuccess(workshopService. @@ -108,22 +49,7 @@ public ResponseEntity> /** 향수공방 목록 조회*/ @GetMapping("/result/list") - @Operation( - summary = "향수공방 목록 조회 (마이페이지)", - description = "해당 유저가 저장한 향수공방 결과 목록을 조회합니다.", - responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse( - responseCode = "COMMON200", - description = "요청에 성공하였습니다.", - content = @Content( - mediaType = "application/json", - schema = @Schema(implementation = WorkshopResponseDTO.WorkshopListResponseDTO.class) - ) - ) - } - ) public ResponseEntity>> getWorkshopList( - @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails ) { return ResponseEntity.ok(ApiResponse.onSuccess(workshopService @@ -133,36 +59,8 @@ public ResponseEntity> getWorkshopDetail( - @Parameter(description = "조회할 향수공방 결과 ID", required = true, example = "1") @PathVariable Long workshopId, - @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails ) { return ResponseEntity.ok(ApiResponse.onSuccess(workshopService diff --git a/src/main/java/PerfumeOnMe/spring/web/docs/imagekeyword/ImageKeywordControllerDocs.java b/src/main/java/PerfumeOnMe/spring/web/docs/imagekeyword/ImageKeywordControllerDocs.java new file mode 100644 index 0000000..5ec7421 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/docs/imagekeyword/ImageKeywordControllerDocs.java @@ -0,0 +1,96 @@ +package PerfumeOnMe.spring.web.docs.imagekeyword; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; +import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordRequestDTO; +import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Image-Keyword", description = "이미지키워드 API") +public interface ImageKeywordControllerDocs { + + @Operation( + summary = "이미지 키워드 목록 조회 (마이페이지)", + description = "해당 유저가 저장한 이미지 키워드 결과 목록을 조회합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON200", + description = "요청에 성공하였습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ImageKeywordResponseDTO.ImageKeywordListResponseDTO.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON401", + description = "인증이 필요합니다. 액세스 토큰을 입력해주세요." + ) + } + ) + ResponseEntity>> getImageKeywordList( + @Parameter(hidden = true) + @AuthenticationPrincipal CustomUserDetails userDetails + ); + + @Operation( + summary = "이미지 키워드 상세 조회", + description = "저장된 이미지 키워드 결과의 상세정보를 조회하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "IK4004", description = "해당 ID의 이미지 키워드 결과를 찾을 수 없습니다.") + } + ) + ResponseEntity> getImageKeywordDetail( + @PathVariable Long imageKeywordId, + @AuthenticationPrincipal CustomUserDetails userDetails + ); + + @Operation( + summary = "이미지 키워드 결과 미리보기", + description = "5가지 키워드를 기반으로 감성 시나리오 및 향수 추천 결과를 생성하여 미리 확인합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON400", description = "잘못된 요청입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON401", description = "인증이 필요합니다.") + } + ) + ResponseEntity> getImageKeywordPreview( + @Parameter(hidden = true) + @AuthenticationPrincipal CustomUserDetails userDetails, + @Validated @RequestBody ImageKeywordRequestDTO.ImageKeywordPreviewRequestDTO request + ); + + @Operation( + summary = "이미지 키워드 결과 저장", + description = """ + 프리뷰 확인 후 이름을 지정해 최종 저장합니다. + Redis에 임시 저장된 키워드를 기반으로 저장되며, 완료 후 해당 Redis 키는 삭제됩니다. + """, + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON200", + description = "성공입니다.", + content = @Content(schema = @Schema(implementation = ImageKeywordResponseDTO.ImageKeywordSaveResponseDTO.class)) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "IK4002", description = "생성한 이미지 키워드 결과가 만료되었습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "IK4003", description = "이미 동일한 이름으로 저장된 결과가 존재합니다.") + } + ) + ResponseEntity> saveImageKeywordResult( + @Parameter(hidden = true) + @AuthenticationPrincipal CustomUserDetails userDetails, + @Validated @RequestBody ImageKeywordRequestDTO.ImageKeywordSaveRequestDTO request + ); +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/web/docs/workshop/WorkshopControllerDocs.java b/src/main/java/PerfumeOnMe/spring/web/docs/workshop/WorkshopControllerDocs.java new file mode 100644 index 0000000..296e4a0 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/web/docs/workshop/WorkshopControllerDocs.java @@ -0,0 +1,139 @@ +package PerfumeOnMe.spring.web.docs.workshop; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +import PerfumeOnMe.spring.apiPayload.ApiResponse; +import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; +import PerfumeOnMe.spring.web.dto.workshop.WorkshopRequestDTO; +import PerfumeOnMe.spring.web.dto.workshop.WorkshopResponseDTO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; + +@Tag(name = "Workshop", description = "향수공방 API") +public interface WorkshopControllerDocs { + + @Operation( + summary = "향수공방 결과 확인(미리보기)", + description = "사용자가 선택한 향(Top, Middle, Base 노트)과 용량을 바탕으로 향기 해석 결과를 미리 확인합니다. " + + "결과는 Redis에 15분간 임시 저장되며, 향수공방 저장 API 호출 시 활용됩니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON200", + description = "요청에 성공하였습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = WorkshopResponseDTO.WorkshopPreviewResponseDTO.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON401", + description = "인증이 필요합니다. 액세스 토큰을 입력해주세요." + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "WORKSHOP4002", + description = "선택한 노트들의 총 용량은 10을 초과할 수 없습니다." + ) + } + ) + ResponseEntity> getWorkshopPreview( + @Parameter(description = "향수공방 미리보기 생성 요청", required = true) + @RequestBody @Valid WorkshopRequestDTO.WorkshopPreviewRequestDTO request, + @Parameter(hidden = true) + @AuthenticationPrincipal CustomUserDetails userDetails + ); + + @Operation( + summary = "향수공방 결과 저장", + description = "사용자가 미리보기에서 확인한 향수공방 결과를 지정한 이름으로 데이터베이스에 영구 저장합니다. " + + "Redis에 임시 저장된 미리보기 데이터를 사용하므로, 미리보기 생성 후 15분 이내에 호출해야 합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON200", + description = "요청에 성공하였습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = WorkshopResponseDTO.WorkshopSaveResponseDTO.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON401", + description = "인증이 필요합니다. 액세스 토큰을 입력해주세요." + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "WORKSHOP4003", + description = "향수공방 미리보기 결과가 만료되었습니다. 다시 시도해주세요." + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "WORKSHOP4006", + description = "이미 같은 이름으로 저장된 향수공방 결과가 있습니다." + ) + } + ) + ResponseEntity> saveWorkshopResult( + @Parameter(description = "향수공방 저장 요청", required = true) + @RequestBody @Valid WorkshopRequestDTO.WorkshopSaveRequestDTO request, + @Parameter(hidden = true) + @AuthenticationPrincipal CustomUserDetails userDetails + ); + + @Operation( + summary = "향수공방 목록 조회 (마이페이지)", + description = "해당 유저가 저장한 향수공방 결과 목록을 조회합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON200", + description = "요청에 성공하였습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = WorkshopResponseDTO.WorkshopListResponseDTO.class) + ) + ) + } + ) + ResponseEntity>> getWorkshopList( + @Parameter(hidden = true) + @AuthenticationPrincipal CustomUserDetails userDetails + ); + + @Operation( + summary = "향수공방 결과 상세조회", + description = "저장된 향수공방 결과의 상세정보를 조회합니다. 키워드 요약, 인상 설명, 성향 분석, 추천 향수 목록을 포함합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON200", + description = "요청에 성공하였습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = WorkshopResponseDTO.WorkshopDetailResponseDTO.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON401", + description = "인증이 필요합니다. 액세스 토큰을 입력해주세요." + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "WORKSHOP4004", + description = "해당 향수공방 결과에 접근할 수 없습니다." + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "WORKSHOP4005", + description = "해당 향수공방 결과 정보가 존재하지 않습니다." + ) + } + ) + ResponseEntity> getWorkshopDetail( + @Parameter(description = "조회할 향수공방 결과 ID", required = true, example = "1") + @PathVariable Long workshopId, + @Parameter(hidden = true) + @AuthenticationPrincipal CustomUserDetails userDetails + ); +} \ No newline at end of file From 195d447796d5621b56d821a05f0f9175486a7a0e Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Wed, 30 Jul 2025 19:17:10 +0900 Subject: [PATCH 287/339] =?UTF-8?q?[refactor]=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=84=B1=EB=B3=84,?= =?UTF-8?q?=20=EC=84=B1=EA=B2=A9=20enum=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/PerfumeOnMe/spring/domain/enums/Gender.java | 2 +- src/main/java/PerfumeOnMe/spring/domain/enums/Personality.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Gender.java b/src/main/java/PerfumeOnMe/spring/domain/enums/Gender.java index 0c66b54..b942147 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/Gender.java +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/Gender.java @@ -4,7 +4,7 @@ @Getter public enum Gender { - FEMININE("여성스러운"), + FEMININE("여성적인"), MASCULINE("남성적인"), NEUTRAL("중성적인"); diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Personality.java b/src/main/java/PerfumeOnMe/spring/domain/enums/Personality.java index d0b639e..cd91384 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/Personality.java +++ b/src/main/java/PerfumeOnMe/spring/domain/enums/Personality.java @@ -6,7 +6,7 @@ public enum Personality { QUIET("조용한"), LOGICAL("논리적인"), - STRONG("개성 강한"), + STRONG("개성강한"), CHARISMATIC("카리스마 있는"), CAUTIOUS("신중한"), LIVELY("활발한"), From 94bd05e13e19e48a129c03d2574d49ce515eb096 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Wed, 30 Jul 2025 19:18:04 +0900 Subject: [PATCH 288/339] =?UTF-8?q?[refactor]=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=ED=96=A5=EC=88=98=EC=97=90=20imageurl=EC=B6=94=EA=B0=80=20(#10?= =?UTF-8?q?5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../external/FastApiRecommendResponse.java | 1 + .../imagekeyword/ImageKeywordRequestDTO.java | 18 ++++++++++--- .../imagekeyword/ImageKeywordResponseDTO.java | 26 +++++++++++++++++-- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiRecommendResponse.java b/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiRecommendResponse.java index 6a6a45f..79e743a 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiRecommendResponse.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiRecommendResponse.java @@ -21,5 +21,6 @@ public static class FragranceRecommendation { private String baseNote; private String description; private List relatedKeywords; + private String imageUrl; } } diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordRequestDTO.java index d1ea7e7..3ba222c 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordRequestDTO.java @@ -6,36 +6,48 @@ import PerfumeOnMe.spring.domain.enums.Season; import PerfumeOnMe.spring.domain.enums.Style; import PerfumeOnMe.spring.validation.annotation.ValidEnumKeyword; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; import lombok.Getter; -import lombok.Setter; /** * 이미지 키워드 관련 요청 DTO 모음 클래스 */ public class ImageKeywordRequestDTO { + @Builder @Getter - @Setter + @Schema(description = "이미지 키워드 결과 생성 요청 DTO") public static class ImageKeywordPreviewRequestDTO { @ValidEnumKeyword(enumClass = Ambience.class) + @NotNull(message = "이미지키워드 분위기 값은 필수입니다.") private String ambience; @ValidEnumKeyword(enumClass = Style.class) + @NotNull(message = "이미지키워드 스타일 값은 필수입니다.") private String style; @ValidEnumKeyword(enumClass = Gender.class) + @NotNull(message = "이미지키워드 성별 값은 필수입니다.") private String gender; @ValidEnumKeyword(enumClass = Season.class) + @NotNull(message = "이미지키워드 계절 값은 필수입니다.") private String season; @ValidEnumKeyword(enumClass = Personality.class) + @NotNull(message = "이미지키워드 성격 값은 필수입니다.") private String personality; } + /**이미지 키워드 결과 저장을 위한 요청 DTO*/ @Getter - @Setter + @Builder + @Schema(description = "이미지 키워드 결과 저장을 위한 DTO") public static class ImageKeywordSaveRequestDTO { + @Schema(description = "결과를 저장할 이름", example = "나만의 겨울향기 이미지키워드") + @NotNull(message = "저장할 이름은 필수입니다") private String savedName; } } \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordResponseDTO.java index a0ff39c..77909d8 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordResponseDTO.java @@ -3,12 +3,14 @@ import java.time.LocalDateTime; import java.util.List; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; // 이미지 키워드 응답 DTO +@Schema(description = "이미지 키워드 응답 DTO") public class ImageKeywordResponseDTO { // 이미지키워드 목록 조회 응답 DTO - 마이페이지 목록 조회용 @@ -16,23 +18,41 @@ public class ImageKeywordResponseDTO { @Builder @AllArgsConstructor @NoArgsConstructor + @Schema(description = "이미지 키워드 향수공방 목록 응답") public static class ImageKeywordListResponseDTO { + @Schema(description = "이미지 키워드 아이디", example = "1") private Long imageKeywordId; + + @Schema(description = "저장된 이미지 키워드 이름", example = "이미지 키워드 해봤는데 맘에드는거1") private String savedName; + + @Schema(description = "생성날짜") private LocalDateTime createdAt; } - // 상세 조회용 + /**이미지 키워드 결과 상세조회*/ @Getter @Builder @AllArgsConstructor @NoArgsConstructor + @Schema(description = "이미지 키워드 결과 상세조회") public static class ImageKeywordDetailResponseDTO { + @Schema(description = "저장된 이미지 키워드 이름", example = "이미지 키워드 해봤는데 맘에드는거1") private String savedName; + + @Schema(description = "선택한 이미지 키워드", example = "세련된,유니크한,겨울,조용한,여성적인") private List keywords; + + @Schema(description = "이미지키워드 설명 나열", example = "세련됨은 어쩌구를 의미해요. ... 여성적인은 저쩌구를 의미해요.") private String descriptions; // 설명들 join해서 + + @Schema(description = "감성 시나리오", example = "잔잔한 눈이 내리고, 따뜻한 햇살이 얼굴을 감싸오는 장면에에요.") private String scenario; + + @Schema(description = "감성 캐릭터", example = "www.imagecharacter.com") private String characterImageUrl; + + @Schema(description = "추천 향수") private List recommendations; @Getter @@ -47,10 +67,11 @@ public static class FragranceRecommendation { private String baseNote; private String description; private List relatedKeywords; + private String imageUrl; } } - // Preview 응답용 (preview와 detail은 구조 동일 -> 저장 전 미리보기) + // Preview 응답용 @Getter @Builder @AllArgsConstructor @@ -74,6 +95,7 @@ public static class FragranceRecommendation { private String baseNote; private String description; private List relatedKeywords; + private String imageUrl; } } From 5194442b50038d31aa72426910ab714670597914 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Wed, 30 Jul 2025 19:18:54 +0900 Subject: [PATCH 289/339] =?UTF-8?q?[refactor]=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=EC=88=9C=EC=84=9C,=20=EC=A3=BC=EC=84=9D?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20(#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ImageKeywordController.java | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java b/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java index b4eeaf9..ccb3de3 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java @@ -28,38 +28,19 @@ public class ImageKeywordController implements ImageKeywordControllerDocs { private final ImageKeywordService imageKeywordService; private final ImageKeywordPreviewService previewService; - // 이미지키워드 목록 조회 API - @GetMapping("/result/list") - public ResponseEntity>> getImageKeywordList( - @AuthenticationPrincipal CustomUserDetails userDetails - ) { - List result = imageKeywordService.getImageKeywordList( - userDetails.getUserId()); - return ResponseEntity.ok(ApiResponse.onSuccess(result)); - } - - // 이미지 키워드 결과 상세조회 - @GetMapping("/{imageKeywordId}") - public ResponseEntity> getImageKeywordDetail( - @PathVariable Long imageKeywordId, - @AuthenticationPrincipal CustomUserDetails userDetails - ) { - ImageKeywordResponseDTO.ImageKeywordDetailResponseDTO result = - imageKeywordService.getImageKeywordDetail(userDetails.getUserId(), imageKeywordId); - return ResponseEntity.ok(ApiResponse.onSuccess(result)); - } - - // 이미지 키워드 결과 미리보기 (preview) + /**(1) 이미지 키워드 결과 미리보기 (preview)*/ @PostMapping("/preview") public ResponseEntity> getImageKeywordPreview( @AuthenticationPrincipal CustomUserDetails userDetails, @Validated @RequestBody ImageKeywordRequestDTO.ImageKeywordPreviewRequestDTO request ) { Long userId = userDetails.getUserId(); - ImageKeywordResponseDTO.ImageKeywordPreviewResponseDTO result = previewService.generatePreview(userId, request); + ImageKeywordResponseDTO.ImageKeywordPreviewResponseDTO result = + previewService.generatePreview(userId, request); return ResponseEntity.ok(ApiResponse.onSuccess(result)); } + /**(2) 이미지 키워드 결과 저장 (save)*/ @PostMapping("/save") public ResponseEntity> saveImageKeywordResult( @AuthenticationPrincipal CustomUserDetails userDetails, @@ -70,4 +51,26 @@ public ResponseEntity> getImageKeywordDetail( + @PathVariable Long imageKeywordId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + ImageKeywordResponseDTO.ImageKeywordDetailResponseDTO result = + imageKeywordService.getImageKeywordDetail(userDetails.getUserId(), imageKeywordId); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } + + /**(4) 이미지 키워드 목록 조회 API*/ + @GetMapping("/result/list") + public ResponseEntity>> getImageKeywordList( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + List result = + imageKeywordService.getImageKeywordList( + userDetails.getUserId()); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } + } \ No newline at end of file From c1cc67a0431280c2944b3dbefb20b52ca363ec9d Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Wed, 30 Jul 2025 19:19:42 +0900 Subject: [PATCH 290/339] =?UTF-8?q?[refactor]=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=B6=94=EC=B2=9C?= =?UTF-8?q?=ED=96=A5=EC=88=98=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EC=88=98=EC=A0=95=20(#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/service/imagekeyword/ImageKeywordPreviewService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordPreviewService.java b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordPreviewService.java index cf1bac4..9d28fdf 100644 --- a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordPreviewService.java +++ b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordPreviewService.java @@ -66,6 +66,7 @@ public ImageKeywordPreviewResponseDTO generatePreview(Long userId, ImageKeywordP .baseNote(f.getBaseNote()) .description(f.getDescription()) .relatedKeywords(f.getRelatedKeywords()) + .imageUrl(f.getImageUrl()) .build() ).collect(Collectors.toList()); From 517f07fc662214ac27c0c7e3b8a832e82c077446 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Wed, 30 Jul 2025 20:00:17 +0900 Subject: [PATCH 291/339] =?UTF-8?q?[refactor]=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=8D=BC=ED=8B=B0=20=EC=B6=94=EA=B0=80(#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../properties/ImageKeywordProperties.java | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/config/properties/ImageKeywordProperties.java diff --git a/src/main/java/PerfumeOnMe/spring/config/properties/ImageKeywordProperties.java b/src/main/java/PerfumeOnMe/spring/config/properties/ImageKeywordProperties.java new file mode 100644 index 0000000..ba5d7c1 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/config/properties/ImageKeywordProperties.java @@ -0,0 +1,40 @@ +package PerfumeOnMe.spring.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import lombok.Getter; +import lombok.Setter; + +/**이미지 키워드 관련 설정 프로퍼티*/ +@Component +@ConfigurationProperties(prefix = "app.image-keyword") +@Getter +@Setter +public class ImageKeywordProperties { + + /**FastAPI 연동 설정*/ + private final FastApi fastApi = new FastApi(); + + /**감성 캐릭터 이미지 기본 S3 경로*/ + private String characterImageBasePath = "https://umc-perfume-bucket.s3.ap-northeast-1.amazonaws.com/image-keyword/characters/"; + + /**Redis 캐시 TTL (분 단위)*/ + private int cacheTimeoutMinutes = 5; + + @Getter + @Setter + public static class FastApi { + /**연결 타임아웃 (밀리초)*/ + private int connectTimeout = 5000; + + /**읽기 타임아웃 (밀리초)*/ + private int readTimeout = 10000; + + /**재시도 최대 횟수*/ + private int maxRetries = 3; + + /**재시도 지연 시간 (밀리초)*/ + private int retryDelay = 1000; + } +} \ No newline at end of file From a076c4cd12969efc54ce27f04225427a0b8e0f91 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Wed, 30 Jul 2025 20:06:06 +0900 Subject: [PATCH 292/339] =?UTF-8?q?[refactor]=20IMAGE=5FKEYWORD=5FCHARACTE?= =?UTF-8?q?R=5FBASE=5FPATH=20=EC=B6=94=EA=B0=80(#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index dc75a9b..f496f98 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -12,7 +12,7 @@ spring: mode: never jpa: hibernate: - ddl-auto: create # create X + ddl-auto: update # create X properties: hibernate: dialect: org.hibernate.dialect.MySQL8Dialect @@ -81,4 +81,14 @@ cloud: external: fastapi: recommend-url: ${EXTERNAL_FASTAPI_RECOMMEND_URL:http://dummy-url.com} # 환경변수 없으면 기본값 사용(dummy-url.com) - pbti-recommend-url: ${EXTERNAL_FASTAPI_RECOMMEND_URL:http://dummy-url.com} # 환경변수 없으면 기본값 사용(dummy-url.com) \ No newline at end of file + pbti-recommend-url: ${EXTERNAL_FASTAPI_RECOMMEND_URL:http://dummy-url.com} # 환경변수 없으면 기본값 사용(dummy-url.com) + +app: + image-keyword: + character-image-base-path: ${IMAGE_KEYWORD_CHARACTER_BASE_PATH} + cache-timeout-minutes: 15 + fast-api: + connect-timeout: 5000 + read-timeout: 10000 + max-retries: 3 + retry-delay: 1000 \ No newline at end of file From 8c6286b02de510c26a65f63bffd63c2cf92fe1b0 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Wed, 30 Jul 2025 20:06:57 +0900 Subject: [PATCH 293/339] =?UTF-8?q?[refactor]=20dev=5Fdeploy.yml=20IMAGE?= =?UTF-8?q?=5FKEYWORD=5FCHARACTER=5FBASE=5FPATH=20=EC=B6=94=EA=B0=80(#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev_deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index b3d85b1..4b4d94c 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -93,6 +93,7 @@ jobs: -e AWS_S3_SECRET_ACCESS_KEY=${{ secrets.AWS_S3_SECRET_ACCESS_KEY }} \ -e AWS_S3_BUCKET_NAME=${{ secrets.AWS_S3_BUCKET_NAME }} \ -e EXTERNAL_FASTAPI_RECOMMEND_URL=http://dummy-url.com \ + -e IMAGE_KEYWORD_CHARACTER_BASE_PATH=${{ secrets.IMAGE_KEYWORD_CHARACTER_BASE_PATH }} \ chanee29/perfumeonme - name: Remove GitHub IP FROM security group From 4e6ab6d1fa4b4a0f81b0e96362581c9f99c77f2d Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Wed, 30 Jul 2025 20:07:36 +0900 Subject: [PATCH 294/339] =?UTF-8?q?[refactor]=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9C=A0=ED=8B=B8=ED=95=A8=EC=88=98?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=ED=99=98(#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/util/CharacterImageMapper.java | 56 +++++++++++++ .../util/ImageKeywordDescriptionUtils.java | 84 +++++++++++++++++++ .../util/ImageKeywordValidationUtils.java | 27 ++++++ 3 files changed, 167 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/util/CharacterImageMapper.java create mode 100644 src/main/java/PerfumeOnMe/spring/util/ImageKeywordDescriptionUtils.java create mode 100644 src/main/java/PerfumeOnMe/spring/util/ImageKeywordValidationUtils.java diff --git a/src/main/java/PerfumeOnMe/spring/util/CharacterImageMapper.java b/src/main/java/PerfumeOnMe/spring/util/CharacterImageMapper.java new file mode 100644 index 0000000..e5b101e --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/util/CharacterImageMapper.java @@ -0,0 +1,56 @@ +package PerfumeOnMe.spring.util; + +import java.util.Map; + +import PerfumeOnMe.spring.domain.enums.Ambience; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * 분위기별 감성 캐릭터 이미지 URL 매핑 유틸리티 + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class CharacterImageMapper { + + private static final String BASE_S3_URL = "https://umc-perfume-bucket.s3.ap-northeast-1.amazonaws.com/image-keyword/characters/"; + private static final String DEFAULT_CHARACTER_IMAGE = BASE_S3_URL + "default.png"; + + /** + * 분위기별 캐릭터 이미지 매핑 + */ + private static final Map CHARACTER_IMAGE_MAP = Map.of( + Ambience.SOPHISTICATED, BASE_S3_URL + "sophisticated.png", // 세련된 + Ambience.CUTE, BASE_S3_URL + "cute.png", // 귀여운 + Ambience.CALM, BASE_S3_URL + "calm.png", // 차분한 + Ambience.MATURE, BASE_S3_URL + "mature.png", // 성숙한 + Ambience.LOVELY, BASE_S3_URL + "lovely.png", // 러블리한 + Ambience.ELEGANT, BASE_S3_URL + "elegant.png", // 시크한 + Ambience.FRESH, BASE_S3_URL + "fresh.png", // 신비로운 + Ambience.BRIGHT, BASE_S3_URL + "bright.png", // 밝은 + Ambience.LIVELY, BASE_S3_URL + "lively.png", // 몽환적인 + Ambience.GRACEFUL, BASE_S3_URL + "graceful.png" // 우아한 + ); + + /** + * 분위기에 따른 감성 캐릭터 이미지 URL 반환 + * @param ambience 분위기 Enum + * @return 해당 분위기의 캐릭터 이미지 URL + */ + public static String getCharacterImageUrl(Ambience ambience) { + return CHARACTER_IMAGE_MAP.getOrDefault(ambience, DEFAULT_CHARACTER_IMAGE); + } + + /** + * 분위기 이름(displayName)으로 감성 캐릭터 이미지 URL 반환 + * @param ambienceDisplayName 분위기 이름 (예: "세련된", "귀여운") + * @return 해당 분위기의 캐릭터 이미지 URL + */ + public static String getCharacterImageUrl(String ambienceDisplayName) { + try { + Ambience ambience = Ambience.fromDisplayName(ambienceDisplayName); + return getCharacterImageUrl(ambience); + } catch (IllegalArgumentException e) { + return DEFAULT_CHARACTER_IMAGE; + } + } +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/util/ImageKeywordDescriptionUtils.java b/src/main/java/PerfumeOnMe/spring/util/ImageKeywordDescriptionUtils.java new file mode 100644 index 0000000..25501df --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/util/ImageKeywordDescriptionUtils.java @@ -0,0 +1,84 @@ +package PerfumeOnMe.spring.util; + +import java.util.List; +import java.util.stream.Stream; + +import PerfumeOnMe.spring.domain.ImageKeyword; +import PerfumeOnMe.spring.domain.ImageKeywordDescription; +import PerfumeOnMe.spring.domain.enums.Ambience; +import PerfumeOnMe.spring.domain.enums.Gender; +import PerfumeOnMe.spring.domain.enums.KeywordCategory; +import PerfumeOnMe.spring.domain.enums.Personality; +import PerfumeOnMe.spring.domain.enums.Season; +import PerfumeOnMe.spring.domain.enums.Style; +import PerfumeOnMe.spring.repository.imagekeyworddescription.ImageKeywordDescriptionRepository; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * 이미지 키워드 설명 조회 공통 유틸리티 + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ImageKeywordDescriptionUtils { + + /** + * 키워드 이름들을 기반으로 설명 조합 + * @param ambience 분위기 키워드 + * @param style 스타일 키워드 + * @param gender 성별 키워드 + * @param season 계절 키워드 + * @param personality 성격 키워드 + * @param descriptionRepository 설명 Repository + * @return 조합된 설명 문자열 + */ + public static String getDescriptions(String ambience, String style, String gender, String season, + String personality, ImageKeywordDescriptionRepository descriptionRepository) { + + List descriptions = Stream.of( + new EnumWithCategory(Ambience.fromDisplayName(ambience).name(), KeywordCategory.AMBIENCE), + new EnumWithCategory(Style.fromDisplayName(style).name(), KeywordCategory.STYLE), + new EnumWithCategory(Season.fromDisplayName(season).name(), KeywordCategory.SEASON), + new EnumWithCategory(Personality.fromDisplayName(personality).name(), KeywordCategory.PERSONALITY), + new EnumWithCategory(Gender.fromDisplayName(gender).name(), KeywordCategory.GENDER) + ) + .map(pair -> descriptionRepository + .findByKeywordAndCategory(pair.keyword(), pair.category()) + .map(ImageKeywordDescription::getDescription) + .orElse("")) + .filter(desc -> !desc.isEmpty()) // 빈 문자열 필터링 추가 + .toList(); + + return String.join(" ", descriptions); + } + + /** + * ImageKeyword 엔티티로부터 설명 조합 + * @param keyword ImageKeyword 엔티티 + * @param descriptionRepository 설명 Repository + * @return 조합된 설명 문자열 + */ + public static String getDescriptionsFromEntity(ImageKeyword keyword, + ImageKeywordDescriptionRepository descriptionRepository) { + + List descriptions = Stream.of( + new EnumWithCategory(keyword.getAmbience().name(), KeywordCategory.AMBIENCE), + new EnumWithCategory(keyword.getStyle().name(), KeywordCategory.STYLE), + new EnumWithCategory(keyword.getSeason().name(), KeywordCategory.SEASON), + new EnumWithCategory(keyword.getPersonality().name(), KeywordCategory.PERSONALITY), + new EnumWithCategory(keyword.getGender().name(), KeywordCategory.GENDER) + ) + .map(pair -> descriptionRepository.findByKeywordAndCategory(pair.keyword(), pair.category()) + .map(ImageKeywordDescription::getDescription) + .orElse("")) + .filter(desc -> !desc.isEmpty()) // 빈 문자열 필터링 추가 + .toList(); + + return String.join(" ", descriptions); + } + + /** + * 키워드와 카테고리를 묶는 내부 레코드 + */ + private record EnumWithCategory(String keyword, KeywordCategory category) { + } +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/util/ImageKeywordValidationUtils.java b/src/main/java/PerfumeOnMe/spring/util/ImageKeywordValidationUtils.java new file mode 100644 index 0000000..56d1201 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/util/ImageKeywordValidationUtils.java @@ -0,0 +1,27 @@ +package PerfumeOnMe.spring.util; + +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.domain.User; +import PerfumeOnMe.spring.repository.user.UserRepository; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * 이미지 키워드 기능에서 사용하는 공통 검증 유틸리티 + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ImageKeywordValidationUtils { + + /** + * 사용자 존재 여부 검증 및 조회 + * @param userId 사용자 ID + * @param userRepository 사용자 Repository + * @return 검증된 사용자 엔티티 + * @throws GeneralException 사용자가 존재하지 않는 경우 + */ + public static User validateAndGetUser(Long userId, UserRepository userRepository) { + return userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + } +} \ No newline at end of file From 1dd557dd776a10fd47dbd15bd62f4d8815fcf2b0 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Wed, 30 Jul 2025 20:08:32 +0900 Subject: [PATCH 295/339] =?UTF-8?q?[refactor]=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EA=B0=90=EC=84=B1=20=EC=BA=90=EB=A6=AD=ED=84=B0=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=B0=98=EC=98=81(#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../imagekeyword/ImageKeywordPreviewService.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordPreviewService.java b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordPreviewService.java index 9d28fdf..f26705d 100644 --- a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordPreviewService.java +++ b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordPreviewService.java @@ -8,6 +8,7 @@ import PerfumeOnMe.spring.service.external.FastApiClient; import PerfumeOnMe.spring.service.redis.ImageKeywordRedisService; +import PerfumeOnMe.spring.util.CharacterImageMapper; import PerfumeOnMe.spring.web.dto.external.FastApiRecommendRequest; import PerfumeOnMe.spring.web.dto.external.FastApiRecommendResponse; import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordRequestDTO.ImageKeywordPreviewRequestDTO; @@ -20,7 +21,6 @@ @Transactional public class ImageKeywordPreviewService { - private static final String TEMP_CHARACTER_IMAGE_URL = "https://s3.amazonaws.com/your-bucket/image-keyword/temp-character.png"; private final ImageKeywordDescriptionService descriptionService; private final FastApiClient fastApiClient; private final ImageKeywordRedisService redisService; @@ -70,16 +70,19 @@ public ImageKeywordPreviewResponseDTO generatePreview(Long userId, ImageKeywordP .build() ).collect(Collectors.toList()); - // ✅ 5. 최종 Preview 응답 구성 + // ✅ 5. 분위기에 따른 감성 캐릭터 이미지 URL 선택 + String characterImageUrl = CharacterImageMapper.getCharacterImageUrl(request.getAmbience()); + + // ✅ 6. 최종 Preview 응답 구성 ImageKeywordPreviewResponseDTO previewDTO = ImageKeywordPreviewResponseDTO.builder() .keywords(keywords) .descriptions(descriptions) .scenario(fastApiResponse.getScenario()) - .characterImageUrl(TEMP_CHARACTER_IMAGE_URL) // 추후 S3 동적 처리 예정 + .characterImageUrl(characterImageUrl) .recommendations(recommendations) .build(); - // ✅ 6. Redis 저장 (TTL 15분) + // ✅ 7. Redis 저장 (TTL 15분) redisService.savePreview(userId, previewDTO); return previewDTO; From 941b895828f84c6e320f9fb168a64de084e5e276 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Fri, 1 Aug 2025 19:38:02 +0900 Subject: [PATCH 296/339] =?UTF-8?q?[ci-cd]=20dev=5Fdeploy.yml=20=EC=88=98?= =?UTF-8?q?=EC=A0=95(#116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev_deploy.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index 4b4d94c..207b5a8 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -79,6 +79,7 @@ jobs: sudo docker run -d -p 8080:8080 --name perfumeonme \ --add-host=host.docker.internal:host-gateway \ + --network bridge \ -e SPRING_PROFILES_ACTIVE=dev \ -e DB_URL=${{ secrets.ENV_DB_URL }} \ -e DB_USERNAME=${{ secrets.ENV_DB_USERNAME }} \ @@ -92,7 +93,8 @@ jobs: -e AWS_S3_ACCESS_KEY_ID=${{ secrets.AWS_S3_ACCESS_KEY_ID }} \ -e AWS_S3_SECRET_ACCESS_KEY=${{ secrets.AWS_S3_SECRET_ACCESS_KEY }} \ -e AWS_S3_BUCKET_NAME=${{ secrets.AWS_S3_BUCKET_NAME }} \ - -e EXTERNAL_FASTAPI_RECOMMEND_URL=http://dummy-url.com \ + -e EXTERNAL_FASTAPI_RECOMMEND_URL=http://perfume-recommender-container:8000 \ + -e EXTERNAL_FASTAPI_PBTI_URL=http://perfume-recommender-container:8000 \ -e IMAGE_KEYWORD_CHARACTER_BASE_PATH=${{ secrets.IMAGE_KEYWORD_CHARACTER_BASE_PATH }} \ chanee29/perfumeonme From 45b238e6957dfbd50df0bc68272438084d2d43eb Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Fri, 1 Aug 2025 19:38:19 +0900 Subject: [PATCH 297/339] =?UTF-8?q?[ci-cd]=20application=5Fdev.yml=20?= =?UTF-8?q?=EC=88=98=EC=A0=95(#116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index f496f98..e59b549 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -80,8 +80,8 @@ cloud: external: fastapi: - recommend-url: ${EXTERNAL_FASTAPI_RECOMMEND_URL:http://dummy-url.com} # 환경변수 없으면 기본값 사용(dummy-url.com) - pbti-recommend-url: ${EXTERNAL_FASTAPI_RECOMMEND_URL:http://dummy-url.com} # 환경변수 없으면 기본값 사용(dummy-url.com) + recommend-url: ${EXTERNAL_FASTAPI_RECOMMEND_URL:http://perfume-recommender-container:8000} # Docker 컨테이너 간 통신 + pbti-recommend-url: ${EXTERNAL_FASTAPI_PBTI_URL:http://perfume-recommender-container:8000} # Docker 컨테이너 간 통신 app: image-keyword: From 5c1b74dd4f6c6b27e7771a84383c01e8b8db6ec7 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sat, 2 Aug 2025 02:28:16 +0900 Subject: [PATCH 298/339] =?UTF-8?q?[ci-cd]=20dev=5Fdeploy=20=EC=88=98?= =?UTF-8?q?=EC=A0=95-=20=EB=8F=84=EC=BB=A4=EB=84=A4=ED=8A=B8=EC=9B=8C?= =?UTF-8?q?=ED=81=AC=20=EC=B6=94=EA=B0=80(#116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev_deploy.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index 207b5a8..b20d154 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -76,9 +76,10 @@ jobs: sudo docker pull chanee29/perfumeonme echo "🚀 Starting new container with the following environment variables:" + sudo docker network create perfume-network || true sudo docker run -d -p 8080:8080 --name perfumeonme \ - --add-host=host.docker.internal:host-gateway \ + --network perfume-network \ --network bridge \ -e SPRING_PROFILES_ACTIVE=dev \ -e DB_URL=${{ secrets.ENV_DB_URL }} \ From 4e910ac3503017d4ba525e63f61e99fe22308ba8 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sat, 2 Aug 2025 02:51:21 +0900 Subject: [PATCH 299/339] =?UTF-8?q?[fix]=20network=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=88=98=EC=A0=95(#116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev_deploy.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index b20d154..0693209 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -80,7 +80,6 @@ jobs: sudo docker run -d -p 8080:8080 --name perfumeonme \ --network perfume-network \ - --network bridge \ -e SPRING_PROFILES_ACTIVE=dev \ -e DB_URL=${{ secrets.ENV_DB_URL }} \ -e DB_USERNAME=${{ secrets.ENV_DB_USERNAME }} \ From 699edb12a2d62859e10899760fa5de4b3d236596 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sat, 2 Aug 2025 03:07:34 +0900 Subject: [PATCH 300/339] =?UTF-8?q?[fix]=20fastapi=20url=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8?= =?UTF-8?q?=EA=B9=8C=EC=A7=80=20=ED=8F=AC=ED=95=A8(#116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev_deploy.yml | 4 ++-- src/main/resources/application-dev.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index 0693209..0bff386 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -93,8 +93,8 @@ jobs: -e AWS_S3_ACCESS_KEY_ID=${{ secrets.AWS_S3_ACCESS_KEY_ID }} \ -e AWS_S3_SECRET_ACCESS_KEY=${{ secrets.AWS_S3_SECRET_ACCESS_KEY }} \ -e AWS_S3_BUCKET_NAME=${{ secrets.AWS_S3_BUCKET_NAME }} \ - -e EXTERNAL_FASTAPI_RECOMMEND_URL=http://perfume-recommender-container:8000 \ - -e EXTERNAL_FASTAPI_PBTI_URL=http://perfume-recommender-container:8000 \ + -e EXTERNAL_FASTAPI_RECOMMEND_URL=${{ secrets.FASTAPI_RECOMMEND_URL }} \ + -e EXTERNAL_FASTAPI_PBTI_URL=${{ secrets.FASTAPI_PBTI_URL }} \ -e IMAGE_KEYWORD_CHARACTER_BASE_PATH=${{ secrets.IMAGE_KEYWORD_CHARACTER_BASE_PATH }} \ chanee29/perfumeonme diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index e59b549..3796258 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -80,8 +80,8 @@ cloud: external: fastapi: - recommend-url: ${EXTERNAL_FASTAPI_RECOMMEND_URL:http://perfume-recommender-container:8000} # Docker 컨테이너 간 통신 - pbti-recommend-url: ${EXTERNAL_FASTAPI_PBTI_URL:http://perfume-recommender-container:8000} # Docker 컨테이너 간 통신 + recommend-url: ${EXTERNAL_FASTAPI_RECOMMEND_URL:http://perfume-recommender-container:8000/recommend/full} # Docker 컨테이너 간 통신 + pbti-recommend-url: ${EXTERNAL_FASTAPI_PBTI_URL:http://perfume-recommender-container:8000/recommend/pbti} # Docker 컨테이너 간 통신 app: image-keyword: From 7fe774632f5b7b079a9b77bb54c6923e6910e4d2 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sat, 2 Aug 2025 19:44:03 +0900 Subject: [PATCH 301/339] =?UTF-8?q?[feature]=20=ED=83=91=EB=85=B8=ED=8A=B8?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EC=85=98,=20=EB=A1=9C=EC=A7=81=20=EC=83=9D=EC=84=B1(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../WorkshopFragranceRepository.java | 0 .../validation/annotation/ValidTopNote.java | 9 ++++++++ .../validator/ValidTopNoteValidator.java | 23 +++++++++++++++++++ 3 files changed, 32 insertions(+) rename src/main/java/PerfumeOnMe/spring/repository/{ => workshop}/WorkshopFragranceRepository.java (100%) create mode 100644 src/main/java/PerfumeOnMe/spring/validation/annotation/ValidTopNote.java create mode 100644 src/main/java/PerfumeOnMe/spring/validation/validator/ValidTopNoteValidator.java diff --git a/src/main/java/PerfumeOnMe/spring/repository/WorkshopFragranceRepository.java b/src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopFragranceRepository.java similarity index 100% rename from src/main/java/PerfumeOnMe/spring/repository/WorkshopFragranceRepository.java rename to src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopFragranceRepository.java diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidTopNote.java b/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidTopNote.java new file mode 100644 index 0000000..dcc94c9 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidTopNote.java @@ -0,0 +1,9 @@ +package PerfumeOnMe.spring.validation.annotation; + +public @interface ValidTopNote { + String message() default "탑 노트는 베르가뭇, 레몬, 오렌지, 자몽, 사과, 페퍼민트 중 하나여야 합니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/PerfumeOnMe/spring/validation/validator/ValidTopNoteValidator.java b/src/main/java/PerfumeOnMe/spring/validation/validator/ValidTopNoteValidator.java new file mode 100644 index 0000000..54f7888 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/validation/validator/ValidTopNoteValidator.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.validation.validator; + +import java.util.Set; + +import PerfumeOnMe.spring.validation.annotation.ValidTopNote; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class ValidTopNoteValidator implements ConstraintValidator { + private static final Set VALID_TOP_NOTES = Set.of( + "베르가뭇", "레몬", "오렌지", "자몽", "사과", "페퍼민트" + ); + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + // null 또는 공백 체크 + if (value == null || value.trim().isEmpty()) { + return false; + } + // 허용된 탑 노트 목록에 포함되는지 확인 + return VALID_TOP_NOTES.contains(value.trim()); + } +} From 79294ab0cd0d63a6655b1b56924ff65f9ca4d3e2 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sat, 2 Aug 2025 19:49:59 +0900 Subject: [PATCH 302/339] =?UTF-8?q?[feature]=20=EB=AF=B8=EB=93=A4=EB=85=B8?= =?UTF-8?q?=ED=8A=B8=20=EA=B2=80=EC=A6=9D=20=EC=96=B4=EB=85=B8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98,=20=EB=A1=9C=EC=A7=81=20=EC=83=9D=EC=84=B1(#?= =?UTF-8?q?121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../validation/annotation/ValidTopNote.java | 9 -------- .../annotation/workshop/ValidMiddleNote.java | 23 +++++++++++++++++++ .../annotation/workshop/ValidTopNote.java | 23 +++++++++++++++++++ .../workshop/ValidMiddleNoteValidator.java | 23 +++++++++++++++++++ .../{ => workshop}/ValidTopNoteValidator.java | 4 ++-- 5 files changed, 71 insertions(+), 11 deletions(-) delete mode 100644 src/main/java/PerfumeOnMe/spring/validation/annotation/ValidTopNote.java create mode 100644 src/main/java/PerfumeOnMe/spring/validation/annotation/workshop/ValidMiddleNote.java create mode 100644 src/main/java/PerfumeOnMe/spring/validation/annotation/workshop/ValidTopNote.java create mode 100644 src/main/java/PerfumeOnMe/spring/validation/validator/workshop/ValidMiddleNoteValidator.java rename src/main/java/PerfumeOnMe/spring/validation/validator/{ => workshop}/ValidTopNoteValidator.java (83%) diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidTopNote.java b/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidTopNote.java deleted file mode 100644 index dcc94c9..0000000 --- a/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidTopNote.java +++ /dev/null @@ -1,9 +0,0 @@ -package PerfumeOnMe.spring.validation.annotation; - -public @interface ValidTopNote { - String message() default "탑 노트는 베르가뭇, 레몬, 오렌지, 자몽, 사과, 페퍼민트 중 하나여야 합니다."; - - Class[] groups() default {}; - - Class[] payload() default {}; -} diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/workshop/ValidMiddleNote.java b/src/main/java/PerfumeOnMe/spring/validation/annotation/workshop/ValidMiddleNote.java new file mode 100644 index 0000000..f9291da --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/validation/annotation/workshop/ValidMiddleNote.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.validation.annotation.workshop; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import PerfumeOnMe.spring.validation.validator.workshop.ValidMiddleNoteValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Documented +@Constraint(validatedBy = ValidMiddleNoteValidator.class) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidMiddleNote { + String message() default "미들노트는 장미, 자스민, 라벤더, 일랑일랑, 아이리스, 피오니 중 하나여야 합니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/workshop/ValidTopNote.java b/src/main/java/PerfumeOnMe/spring/validation/annotation/workshop/ValidTopNote.java new file mode 100644 index 0000000..97d0153 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/validation/annotation/workshop/ValidTopNote.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.validation.annotation.workshop; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import PerfumeOnMe.spring.validation.validator.workshop.ValidTopNoteValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Documented +@Constraint(validatedBy = ValidTopNoteValidator.class) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidTopNote { + String message() default "탑 노트는 베르가뭇, 레몬, 오렌지, 자몽, 사과, 페퍼민트 중 하나여야 합니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/src/main/java/PerfumeOnMe/spring/validation/validator/workshop/ValidMiddleNoteValidator.java b/src/main/java/PerfumeOnMe/spring/validation/validator/workshop/ValidMiddleNoteValidator.java new file mode 100644 index 0000000..3063c4c --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/validation/validator/workshop/ValidMiddleNoteValidator.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.validation.validator.workshop; + +import java.util.Set; + +import PerfumeOnMe.spring.validation.annotation.workshop.ValidMiddleNote; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class ValidMiddleNoteValidator implements ConstraintValidator { + + private static final Set VALID_MIDDLE_NOTES = Set.of( + "장미", "자스민", "라벤더", "일랑일랑", "아이리스", "피오니" + ); + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null || value.trim().isEmpty()) { + return false; + } + + return VALID_MIDDLE_NOTES.contains(value.trim()); + } +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/validation/validator/ValidTopNoteValidator.java b/src/main/java/PerfumeOnMe/spring/validation/validator/workshop/ValidTopNoteValidator.java similarity index 83% rename from src/main/java/PerfumeOnMe/spring/validation/validator/ValidTopNoteValidator.java rename to src/main/java/PerfumeOnMe/spring/validation/validator/workshop/ValidTopNoteValidator.java index 54f7888..e6d5f4a 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/validator/ValidTopNoteValidator.java +++ b/src/main/java/PerfumeOnMe/spring/validation/validator/workshop/ValidTopNoteValidator.java @@ -1,8 +1,8 @@ -package PerfumeOnMe.spring.validation.validator; +package PerfumeOnMe.spring.validation.validator.workshop; import java.util.Set; -import PerfumeOnMe.spring.validation.annotation.ValidTopNote; +import PerfumeOnMe.spring.validation.annotation.workshop.ValidTopNote; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; From 07b4508970d45fce2b358e5ebca4f7aa97bddbe2 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sat, 2 Aug 2025 19:52:45 +0900 Subject: [PATCH 303/339] =?UTF-8?q?[feature]=20=EB=B2=A0=EC=9D=B4=EC=8A=A4?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EA=B2=80=EC=A6=9D=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98,=20=EB=A1=9C=EC=A7=81=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../annotation/workshop/ValidBaseNote.java | 24 +++++++++++++++++++ .../workshop/ValidBaseNoteValidator.java | 23 ++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 src/main/java/PerfumeOnMe/spring/validation/annotation/workshop/ValidBaseNote.java create mode 100644 src/main/java/PerfumeOnMe/spring/validation/validator/workshop/ValidBaseNoteValidator.java diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/workshop/ValidBaseNote.java b/src/main/java/PerfumeOnMe/spring/validation/annotation/workshop/ValidBaseNote.java new file mode 100644 index 0000000..0dd1a65 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/validation/annotation/workshop/ValidBaseNote.java @@ -0,0 +1,24 @@ +package PerfumeOnMe.spring.validation.annotation.workshop; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import com.nimbusds.jose.Payload; + +import PerfumeOnMe.spring.validation.validator.workshop.ValidBaseNoteValidator; +import jakarta.validation.Constraint; + +@Documented +@Constraint(validatedBy = ValidBaseNoteValidator.class) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidBaseNote { + String message() default "베이스노트는 바닐라, 머스크, 샌달우드, 패츌리, 앰버, 시더우드 중 하나여야 합니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/validation/validator/workshop/ValidBaseNoteValidator.java b/src/main/java/PerfumeOnMe/spring/validation/validator/workshop/ValidBaseNoteValidator.java new file mode 100644 index 0000000..56a1df6 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/validation/validator/workshop/ValidBaseNoteValidator.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.validation.validator.workshop; + +import java.util.Set; + +import PerfumeOnMe.spring.validation.annotation.workshop.ValidBaseNote; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class ValidBaseNoteValidator implements ConstraintValidator { + + private static final Set VALID_BASE_NOTES = Set.of( + "바닐라", "머스크", "샌달우드", "패츌리", "앰버", "시더우드" + ); + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null || value.trim().isEmpty()) { + return false; + } + + return VALID_BASE_NOTES.contains(value.trim()); + } +} \ No newline at end of file From ce5313694e3e94681d673ef950fae3ac77e78fca Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sat, 2 Aug 2025 19:56:30 +0900 Subject: [PATCH 304/339] =?UTF-8?q?[feature]=20=ED=96=A5=EC=88=98=EA=B3=B5?= =?UTF-8?q?=EB=B0=A9=20=EA=B2=B0=EA=B3=BC=EC=83=9D=EC=84=B1=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=EC=97=90=20=EA=B2=80=EC=A6=9D=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workshop/WorkshopFragranceRepository.java | 29 +++++----- .../WorkshopRecommendationService.java | 58 ++++++++++--------- .../web/dto/workshop/WorkshopRequestDTO.java | 8 ++- 3 files changed, 53 insertions(+), 42 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopFragranceRepository.java b/src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopFragranceRepository.java index 12d50c8..4d6c9f8 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopFragranceRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopFragranceRepository.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.repository; +package PerfumeOnMe.spring.repository.workshop; import java.util.List; @@ -12,31 +12,32 @@ public interface WorkshopFragranceRepository extends JpaRepository findByNoteContaining(@Param("note") String note); // 메인어코드로 향수 검색 (1,2,3순위에서 매칭) @Query("SELECT wf FROM WorkshopFragrance wf WHERE " + - "wf.mainAccord1 = :accord OR " + - "wf.mainAccord2 = :accord OR " + - "wf.mainAccord3 = :accord") + "wf.mainAccord1 = :accord OR " + + "wf.mainAccord2 = :accord OR " + + "wf.mainAccord3 = :accord") List findByMainAccordContaining(@Param("accord") String accord); // 특정 노트들이 포함된 향수 검색 (복수 노트) @Query("SELECT wf FROM WorkshopFragrance wf WHERE " + - "(:topNote IS NULL OR wf.topNote LIKE %:topNote%) AND " + - "(:middleNote IS NULL OR wf.middleNote LIKE %:middleNote%) AND " + - "(:baseNote IS NULL OR wf.baseNote LIKE %:baseNote%)") + "(:topNote IS NULL OR wf.topNote LIKE %:topNote%) AND " + + "(:middleNote IS NULL OR wf.middleNote LIKE %:middleNote%) AND " + + "(:baseNote IS NULL OR wf.baseNote LIKE %:baseNote%)") List findByNotesContaining( - @Param("topNote") String topNote, - @Param("middleNote") String middleNote, - @Param("baseNote") String baseNote); + @Param("topNote") String topNote, + @Param("middleNote") String middleNote, + @Param("baseNote") String baseNote); // 가격 범위로 향수 검색 @Query("SELECT wf FROM WorkshopFragrance wf WHERE wf.price BETWEEN :minPrice AND :maxPrice") - List findByPriceBetween(@Param("minPrice") Integer minPrice, @Param("maxPrice") Integer maxPrice); + List findByPriceBetween(@Param("minPrice") Integer minPrice, + @Param("maxPrice") Integer maxPrice); // 모든 향수 조회 (추천 알고리즘용) @Query("SELECT wf FROM WorkshopFragrance wf ORDER BY wf.id") diff --git a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopRecommendationService.java b/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopRecommendationService.java index 638ebcd..24467b9 100644 --- a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopRecommendationService.java +++ b/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopRecommendationService.java @@ -10,7 +10,7 @@ import org.springframework.stereotype.Service; import PerfumeOnMe.spring.domain.WorkshopFragrance; -import PerfumeOnMe.spring.repository.WorkshopFragranceRepository; +import PerfumeOnMe.spring.repository.workshop.WorkshopFragranceRepository; import PerfumeOnMe.spring.web.dto.workshop.WorkshopRequestDTO; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -20,28 +20,25 @@ @Slf4j public class WorkshopRecommendationService { - private final WorkshopFragranceRepository workshopFragranceRepository; - // 점수 가중치 상수 private static final double NOTE_WEIGHT = 0.4; // 노트 매칭 40% private static final double ACCORD_WEIGHT = 0.6; // 메인어코드 매칭 60% - // 노트 매칭 점수 private static final double PERFECT_NOTE_MATCH = 100.0; // 정확한 위치 매치 private static final double DIFFERENT_POSITION_MATCH = 50.0; // 다른 위치 매치 private static final double NO_MATCH_PENALTY = -10.0; // 매치 없음 페널티 - // 메인어코드 매칭 점수 private static final double FIRST_ACCORD_MATCH = 100.0; // 1순위 매치 private static final double SECOND_ACCORD_MATCH = 60.0; // 2순위 매치 private static final double THIRD_ACCORD_MATCH = 30.0; // 3순위 매치 + private final WorkshopFragranceRepository workshopFragranceRepository; /** * 사용자의 향수공방 선택을 기반으로 상위 3개 향수 추천 */ public List recommendFragrances(WorkshopRequestDTO.WorkshopCreateRequestDTO request) { - log.info("향수 추천 시작 - 사용자 노트 선택: Top={}, Middle={}, Base={}", - request.getTopNoteList(), request.getMiddleNoteList(), request.getBaseNoteList()); + log.info("향수 추천 시작 - 사용자 노트 선택: Top={}, Middle={}, Base={}", + request.getTopNoteList(), request.getMiddleNoteList(), request.getBaseNoteList()); // 모든 향수 조회 List allFragrances = workshopFragranceRepository.findAllForRecommendation(); @@ -49,15 +46,15 @@ public List recommendFragrances(WorkshopRequestDTO.WorkshopCr // 각 향수에 대해 점수 계산 List fragranceScores = allFragrances.stream() - .map(fragrance -> calculateScore(fragrance, request)) - .collect(Collectors.toList()); + .map(fragrance -> calculateScore(fragrance, request)) + .collect(Collectors.toList()); // 점수 기준으로 정렬하고 상위 3개 선택 List recommendations = fragranceScores.stream() - .sorted(Comparator.comparingDouble(FragranceScore::getScore).reversed()) - .limit(3) - .map(FragranceScore::getFragrance) - .collect(Collectors.toList()); + .sorted(Comparator.comparingDouble(FragranceScore::getScore).reversed()) + .limit(3) + .map(FragranceScore::getFragrance) + .collect(Collectors.toList()); log.info("추천 완료 - 상위 3개 향수 선택됨"); return recommendations; @@ -66,7 +63,8 @@ public List recommendFragrances(WorkshopRequestDTO.WorkshopCr /** * 향수에 대한 종합 점수 계산 */ - private FragranceScore calculateScore(WorkshopFragrance fragrance, WorkshopRequestDTO.WorkshopCreateRequestDTO request) { + private FragranceScore calculateScore(WorkshopFragrance fragrance, + WorkshopRequestDTO.WorkshopCreateRequestDTO request) { double noteScore = calculateNoteMatchingScore(fragrance, request); double accordScore = calculateAccordMatchingScore(fragrance, request); @@ -78,7 +76,8 @@ private FragranceScore calculateScore(WorkshopFragrance fragrance, WorkshopReque /** * 노트 매칭 점수 계산 (40% 가중치) */ - private double calculateNoteMatchingScore(WorkshopFragrance fragrance, WorkshopRequestDTO.WorkshopCreateRequestDTO request) { + private double calculateNoteMatchingScore(WorkshopFragrance fragrance, + WorkshopRequestDTO.WorkshopCreateRequestDTO request) { double totalScore = 0.0; int totalWeight = 0; @@ -91,7 +90,7 @@ private double calculateNoteMatchingScore(WorkshopFragrance fragrance, WorkshopR for (Map.Entry entry : userTopNotes.entrySet()) { String noteName = entry.getKey(); int volume = entry.getValue(); - + double score = calculateSingleNoteScore(fragrance, noteName, "top"); totalScore += score * volume; totalWeight += volume; @@ -101,7 +100,7 @@ private double calculateNoteMatchingScore(WorkshopFragrance fragrance, WorkshopR for (Map.Entry entry : userMiddleNotes.entrySet()) { String noteName = entry.getKey(); int volume = entry.getValue(); - + double score = calculateSingleNoteScore(fragrance, noteName, "middle"); totalScore += score * volume; totalWeight += volume; @@ -111,7 +110,7 @@ private double calculateNoteMatchingScore(WorkshopFragrance fragrance, WorkshopR for (Map.Entry entry : userBaseNotes.entrySet()) { String noteName = entry.getKey(); int volume = entry.getValue(); - + double score = calculateSingleNoteScore(fragrance, noteName, "base"); totalScore += score * volume; totalWeight += volume; @@ -131,13 +130,16 @@ private double calculateSingleNoteScore(WorkshopFragrance fragrance, String note // 정확한 위치에서 매치 switch (expectedPosition) { case "top": - if (topNote.contains(noteName)) return PERFECT_NOTE_MATCH; + if (topNote.contains(noteName)) + return PERFECT_NOTE_MATCH; break; case "middle": - if (middleNote.contains(noteName)) return PERFECT_NOTE_MATCH; + if (middleNote.contains(noteName)) + return PERFECT_NOTE_MATCH; break; case "base": - if (baseNote.contains(noteName)) return PERFECT_NOTE_MATCH; + if (baseNote.contains(noteName)) + return PERFECT_NOTE_MATCH; break; } @@ -153,7 +155,8 @@ private double calculateSingleNoteScore(WorkshopFragrance fragrance, String note /** * 메인어코드 매칭 점수 계산 (60% 가중치) */ - private double calculateAccordMatchingScore(WorkshopFragrance fragrance, WorkshopRequestDTO.WorkshopCreateRequestDTO request) { + private double calculateAccordMatchingScore(WorkshopFragrance fragrance, + WorkshopRequestDTO.WorkshopCreateRequestDTO request) { // 사용자가 선택한 모든 노트 수집 List userSelectedNotes = new ArrayList<>(); userSelectedNotes.addAll(request.getTopNoteList().keySet()); @@ -165,19 +168,20 @@ private double calculateAccordMatchingScore(WorkshopFragrance fragrance, Worksho // 향수의 메인어코드들 List fragranceAccords = Arrays.asList( - fragrance.getMainAccord1(), - fragrance.getMainAccord2(), - fragrance.getMainAccord3() + fragrance.getMainAccord1(), + fragrance.getMainAccord2(), + fragrance.getMainAccord3() ); // 각 메인어코드에 대해 점수 계산 for (int i = 0; i < fragranceAccords.size(); i++) { String accord = fragranceAccords.get(i); - if (accord == null || accord.trim().isEmpty()) continue; + if (accord == null || accord.trim().isEmpty()) + continue; // 사용자 선택 노트와 매칭 확인 boolean matched = userSelectedNotes.stream() - .anyMatch(note -> accord.contains(note) || note.contains(accord)); + .anyMatch(note -> accord.contains(note) || note.contains(accord)); if (matched) { switch (i) { diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopRequestDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopRequestDTO.java index 326fe24..59797b3 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopRequestDTO.java @@ -2,6 +2,9 @@ import java.util.Map; +import PerfumeOnMe.spring.validation.annotation.workshop.ValidBaseNote; +import PerfumeOnMe.spring.validation.annotation.workshop.ValidMiddleNote; +import PerfumeOnMe.spring.validation.annotation.workshop.ValidTopNote; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; @@ -21,6 +24,7 @@ public static class WorkshopPreviewRequestDTO { @Schema(description = "탑 노트", example = "베르가못") @NotNull(message = "탑 노트 값은 필수 입니다") + @ValidTopNote private String topNote; @Schema(description = "탑 노트 용량", example = "3") @@ -30,7 +34,8 @@ public static class WorkshopPreviewRequestDTO { private Long topNoteVolume; @Schema(description = "미들 노트", example = "장미") - @NotNull(message = "미들노트 값은 필수 입니다") + @NotNull(message = "미들 노트 값은 필수 입니다") + @ValidMiddleNote private String middleNote; @Schema(description = "미들 노트 용량", example = "3") @@ -41,6 +46,7 @@ public static class WorkshopPreviewRequestDTO { @Schema(description = "베이스 노트", example = "바닐라") @NotNull(message = "베이스 노트 값은 필수 입니다") + @ValidBaseNote private String baseNote; @Schema(description = "베이스 노트 용량", example = "4") From cc65c900b311283ca9410740c3dc1077137e123b Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sat, 2 Aug 2025 20:45:49 +0900 Subject: [PATCH 305/339] =?UTF-8?q?[refactor]=20=ED=96=A5=EC=88=98?= =?UTF-8?q?=EA=B3=B5=EB=B0=A9=20=EB=B0=98=ED=99=98=EA=B0=92=20=EC=84=B1?= =?UTF-8?q?=ED=96=A5(Tendecy)=20->=20=EC=84=B1=ED=96=A5(Tendency)=20,=20?= =?UTF-8?q?=EA=B8=B0=EC=96=B5=EB=90=98=EB=8A=94=20=EB=AA=A8=EC=8A=B5(remem?= =?UTF-8?q?berd)=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/converter/WorkshopConverter.java | 4 +++ .../PerfumeOnMe/spring/domain/Workshop.java | 3 ++ .../service/workshop/WorkshopResult.java | 3 +- .../workshop/WorkshopResultParser.java | 35 +++++++++++-------- .../service/workshop/WorkshopService.java | 16 +++++---- .../web/dto/workshop/WorkshopResponseDTO.java | 10 ++++-- 6 files changed, 47 insertions(+), 24 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java b/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java index 20e8529..9e643d2 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java @@ -41,6 +41,7 @@ public static WorkshopResponseDTO.WorkshopDetailResponseDTO toWorkshopDetailResp .centerImpression(workshop.getCenterImpression()) .lastImpression(workshop.getLastImpression()) .tendency(workshop.getTendency()) + .remembered(workshop.getRemembered()) .recommendedFragranceJson(recommendedFragranceDTOList) .build(); } @@ -65,6 +66,7 @@ public static WorkshopResponseDTO.WorkshopPreviewResponseDTO toWorkshopPreviewRe .centerImpression(workshopResult.getCenterImpression()) .lastImpression(workshopResult.getLastImpression()) .tendency(workshopResult.getTendency()) + .remembered(workshopResult.getRemembered()) .recommendedFragranceJson(emptyRecommendations) .build(); } @@ -99,6 +101,7 @@ public static WorkshopResponseDTO.WorkshopPreviewResponseDTO toWorkshopPreviewRe .centerImpression(workshopResult.getCenterImpression()) .lastImpression(workshopResult.getLastImpression()) .tendency(workshopResult.getTendency()) + .remembered(workshopResult.getRemembered()) .recommendedFragranceJson(recommendedFragranceDTOList) .build(); } @@ -133,6 +136,7 @@ public static Workshop toWorkshopEntity( .centerImpression(previewData.getCenterImpression()) .lastImpression(previewData.getLastImpression()) .tendency(previewData.getTendency()) + .remembered(previewData.getRemembered()) .recommendedFragranceJson(recommendedFragranceJson) .build(); } diff --git a/src/main/java/PerfumeOnMe/spring/domain/Workshop.java b/src/main/java/PerfumeOnMe/spring/domain/Workshop.java index fbfbe1c..54f4294 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Workshop.java +++ b/src/main/java/PerfumeOnMe/spring/domain/Workshop.java @@ -72,6 +72,9 @@ public class Workshop extends BaseEntity { @Column(columnDefinition = "TEXT", nullable = false) private String tendency; + @Column(columnDefinition = "TEXT", nullable = false) + private String remembered; + @Column(columnDefinition = "TEXT", nullable = false) private String recommendedFragranceJson; } diff --git a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopResult.java b/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopResult.java index abc65de..4ec29c6 100644 --- a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopResult.java +++ b/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopResult.java @@ -18,5 +18,6 @@ public class WorkshopResult { private String firstImpression; // 향기의 첫인상 (탑 노트 설명 + 사용자 성향) private String centerImpression; // 중심을 잡는 향 (미들 노트 설명 + 사용자 성향) private String lastImpression; // 마지막에 남는 잔향 (베이스 노트 설명 + 사용자 성향) - private String tendency; // 향기로 해석한 당신의 성향 (전체 분석 + 기억되는 모습) + private String tendency; // 향기로 해석한 당신의 성향 (성향 분석 부분) + private String remembered; // 기억되는 모습 (당신은 사람들에게 ~으로 기억됩니다) } \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopResultParser.java b/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopResultParser.java index fbcf424..ec09c5c 100644 --- a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopResultParser.java +++ b/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopResultParser.java @@ -10,73 +10,78 @@ */ @Slf4j public class WorkshopResultParser { - + /** * GPT 응답 텍스트를 파싱하여 WorkshopResult 객체로 변환 - * + * * 예상 GPT 응답 형식: * 시각적 키워드: #상큼한첫인상 #감성적중심 #우디잔향 * #깊이있는사람 #신뢰감있는향기 * 향기의 첫인상: 베르가못의 상쾌한 시트러스 향이 첫 만남을 장식합니다. 이런 향을 선택하는 당신은 활기차고 긍정적인 에너지를 가진 사람으로 보입니다. * 중심을 잡는 향: 장미의 우아한 플로럴 향이 중심을 잡으며 로맨틱함을 연출합니다. 이런 향을 좋아하는 당신은 섬세하고 감성적인 면을 가진 사람입니다. * 마지막에 남는 잔향: 샌달우드의 깊고 따뜻한 잔향이 오래도록 머물며 안정감을 줍니다. 이런 향을 선택하는 당신은 차분하고 신뢰할 수 있는 성격의 소유자입니다. - * 향기로 해석한 당신의 성향: 복합적이고 다층적인 매력을 가진 당신은 첫인상은 밝고 활기차지만, 깊이 알수록 더욱 매력적인 면을 발견하게 됩니다. 당신은 사람들에게 '기분 좋은 여운이 오래 남는 사람'으로 기억됩니다. + * 향기로 해석한 당신의 성향: 복합적이고 다층적인 매력을 가진 당신은 첫인상은 밝고 활기차지만, 깊이 알수록 더욱 매력적인 면을 발견하게 됩니다. + * 기억되는 모습: 당신은 사람들에게 '기분 좋은 여운이 오래 남는 사람'으로 기억됩니다. */ public WorkshopResult parseGptResponse(String gptResponse) { try { - log.info("GPT 응답 파싱 시작"); - + log.info("GPT 응답 파싱 시작" + gptResponse); + String keywordSummary = extractValue(gptResponse, "시각적 키워드:"); String firstImpression = extractValue(gptResponse, "향기의 첫인상:"); String centerImpression = extractValue(gptResponse, "중심을 잡는 향:"); String lastImpression = extractValue(gptResponse, "마지막에 남는 잔향:"); String tendency = extractValue(gptResponse, "향기로 해석한 당신의 성향:"); - + String remembered = extractValue(gptResponse, "기억되는 모습:"); + WorkshopResult result = WorkshopResult.builder() .keywordSummary(keywordSummary) .firstImpression(firstImpression) .centerImpression(centerImpression) .lastImpression(lastImpression) .tendency(tendency) + .remembered(remembered) .build(); - + log.info("GPT 응답 파싱 완료 - 시각적 키워드: {}", keywordSummary); return result; - + } catch (Exception e) { log.error("GPT 응답 파싱 중 오류 발생: {}", e.getMessage(), e); log.error("원본 GPT 응답: {}", gptResponse); - + // 파싱 실패 시 기본값 반환 return WorkshopResult.builder() .keywordSummary("#맞춤형향수 #개성있는조합 #특별한향기\n#매력적인사람 #기억에남는향") .firstImpression("선택한 탑 노트가 상쾌한 첫인상을 선사합니다. 당신은 활기찬 에너지를 가진 사람으로 보입니다.") .centerImpression("미들 노트가 조화로운 중심을 잡아줍니다. 당신은 균형감 있는 성격의 소유자입니다.") .lastImpression("베이스 노트가 깊이 있는 잔향을 남깁니다. 당신은 신뢰할 수 있는 매력을 가진 사람입니다.") - .tendency("개성 있는 향을 추구하는 당신은 자신만의 스타일을 가진 사람입니다. 당신은 사람들에게 '특별한 매력을 가진 사람'으로 기억됩니다.") + .tendency("개성 있는 향을 추구하는 당신은 자신만의 스타일을 가진 사람입니다.") + .remembered("당신은 사람들에게 '특별한 매력을 가진 사람'으로 기억됩니다.") .build(); } } - + /** * 특정 키워드 뒤의 값을 추출하는 헬퍼 메서드 */ private String extractValue(String text, String keyword) { try { // 키워드 뒤에 오는 내용을 다음 키워드나 문서 끝까지 추출 - String regex = keyword + "\\s*([^\\n]*(?:\\n(?!시각적 키워드:|향기의 첫인상:|중심을 잡는 향:|마지막에 남는 잔향:|향기로 해석한 당신의 성향:)[^\\n]*)*)"; + String regex = keyword + + "\\s*([^\\n]*(?:\\n(?!시각적 키워드:|향기의 첫인상:|중심을 잡는 향:|마지막에 남는 잔향:|향기로 해석한 당신의 성향:|기억되는 모습:)[^\\n]*)*)"; Pattern pattern = Pattern.compile(regex, Pattern.MULTILINE | Pattern.DOTALL); Matcher matcher = pattern.matcher(text); - + if (matcher.find()) { String value = matcher.group(1).trim(); log.debug("추출된 값 - {}: {}", keyword, value); return value; } - + log.warn("키워드 '{}'에 대한 값을 찾을 수 없습니다.", keyword); return "정보를 찾을 수 없습니다."; - + } catch (Exception e) { log.error("값 추출 중 오류 발생 - 키워드: {}, 오류: {}", keyword, e.getMessage()); return "정보를 찾을 수 없습니다."; diff --git a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java b/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java index 4c56d0f..c370dc7 100644 --- a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java +++ b/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java @@ -35,6 +35,7 @@ public class WorkshopService { private final WorkshopRedisService workshopRedisService; private final WorkshopRecommendationService workshopRecommendationService; + /** (마이페이지) 향수공방 목록 조회*/ @Transactional(readOnly = true) public List findAllWorkshopsByUser(CustomUserDetails userDetails) { @@ -55,6 +56,7 @@ public List findAllWorkshopsByUser( return WorkshopConverter.toWorkshopListResponse(workshops); } + /** 향수공방 결과 저장*/ @Transactional public WorkshopResponseDTO.WorkshopSaveResponseDTO saveWorkshop( WorkshopRequestDTO.WorkshopSaveRequestDTO request, CustomUserDetails userDetails @@ -78,7 +80,7 @@ public WorkshopResponseDTO.WorkshopSaveResponseDTO saveWorkshop( log.info("향수공방 결과 저장 시작 - 사용자 ID: {}, 저장 이름: {}", userId, request.getSavedName()); // Redis에서 미리보기 결과 조회 - WorkshopResponseDTO.WorkshopPreviewResponseDTO previewData = + WorkshopResponseDTO.WorkshopPreviewResponseDTO previewData = workshopRedisService.getPreview(userId); // 추천 향수 리스트 JSON 직렬화 @@ -88,12 +90,12 @@ public WorkshopResponseDTO.WorkshopSaveResponseDTO saveWorkshop( // Workshop 엔티티 생성 및 저장 Workshop workshop = WorkshopConverter.toWorkshopEntity( - user, - request.getSavedName(), - previewData, + user, + request.getSavedName(), + previewData, recommendedFragranceJson ); - + Workshop savedWorkshop = workshopRepository.save(workshop); // Redis 임시 데이터 삭제 @@ -131,9 +133,11 @@ public WorkshopResponseDTO.WorkshopDetailResponseDTO findWorkshopById( return WorkshopConverter.toWorkshopDetailResponse(workshop); } + /**향수공방 결과 미리보기 서비스*/ @Transactional public WorkshopResponseDTO.WorkshopPreviewResponseDTO createWorkshopPreview( - WorkshopRequestDTO.WorkshopPreviewRequestDTO request, CustomUserDetails userDetails + WorkshopRequestDTO.WorkshopPreviewRequestDTO request, + CustomUserDetails userDetails ) { Long userId = userDetails.getUserId(); diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java index 159ed44..947e898 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java @@ -48,9 +48,12 @@ public static class WorkshopDetailResponseDTO { @Schema(description = "마지막에 남는 잔향 (베이스 노트 설명 + 사용자 성향)", example = "샌달우드의 깊고 따뜻한 잔향이 오래도록 머물며 안정감을 줍니다. 이런 향을 선택하는 당신은 차분하고 신뢰할 수 있는 성격의 소유자입니다.") private String lastImpression; - @Schema(description = "향기로 해석한 당신의 성향 (전체 분석 + 기억되는 모습)", example = "복합적이고 다층적인 매력을 가진 당신은 첫인상은 밝고 활기차지만, 깊이 알수록 더욱 매력적인 면을 발견하게 됩니다. 당신은 사람들에게 '기분 좋은 여운이 오래 남는 사람'으로 기억됩니다.") + @Schema(description = "향기로 해석한 당신의 성향 (전체 분석)", example = "복합적이고 다층적인 매력을 가진 당신은 첫인상은 밝고 활기차지만, 깊이 알수록 더욱 매력적인 면을 발견하게 됩니다. 당신은 사람들에게 '기분 좋은 여운이 오래 남는 사람'으로 기억됩니다.") private String tendency; + @Schema(description = "향기로 해석한 당신의 성향 (기억되는 모습)", example = "당신은 사람들에게 '기분 좋은 여운이 오래 남는 사람'으로 기억됩니다.") + private String remembered; + @Schema(description = "추천 향수 목록") @JsonProperty("recommendedFragranceJson") private List recommendedFragranceJson; @@ -93,9 +96,12 @@ public static class WorkshopPreviewResponseDTO { @Schema(description = "마지막에 남는 잔향 (베이스 노트 설명 + 사용자 성향)", example = "샌달우드의 깊고 따뜻한 잔향이 오래도록 머물며 안정감을 줍니다. 이런 향을 선택하는 당신은 차분하고 신뢰할 수 있는 성격의 소유자입니다.") private String lastImpression; - @Schema(description = "향기로 해석한 당신의 성향 (전체 분석 + 기억되는 모습)", example = "복합적이고 다층적인 매력을 가진 당신은 첫인상은 밝고 활기차지만, 깊이 알수록 더욱 매력적인 면을 발견하게 됩니다. 당신은 사람들에게 '기분 좋은 여운이 오래 남는 사람'으로 기억됩니다.") + @Schema(description = "향기로 해석한 당신의 성향 (전체 분석)", example = "복합적이고 다층적인 매력을 가진 당신은 첫인상은 밝고 활기차지만, 깊이 알수록 더욱 매력적인 면을 발견하게 됩니다. 당신은 사람들에게 '기분 좋은 여운이 오래 남는 사람'으로 기억됩니다.") private String tendency; + @Schema(description = "향기로 해석한 당신의 성향 (기억되는 모습)", example = "당신은 사람들에게 '기분 좋은 여운이 오래 남는 사람'으로 기억됩니다.") + private String remembered; + @Schema(description = "추천 향수 목록") @JsonProperty("recommendedFragranceJson") private List recommendedFragranceJson; From d3152fcad705d63c9221b6b5cb204a35fc446e16 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sat, 2 Aug 2025 20:46:17 +0900 Subject: [PATCH 306/339] =?UTF-8?q?[refactor]=20=EA=B8=B0=EC=96=B5?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=EB=AA=A8=EC=8A=B5=20=EC=B6=9C=EB=A0=A5?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=ED=94=84=EB=A1=AC=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/prompts/workshop.txt | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/main/resources/prompts/workshop.txt b/src/main/resources/prompts/workshop.txt index 27ab0c2..4c0c51c 100644 --- a/src/main/resources/prompts/workshop.txt +++ b/src/main/resources/prompts/workshop.txt @@ -23,10 +23,12 @@ 4. **마지막에 남는 잔향 (lastImpression)**: 베이스 노트({baseNoteType})에 대한 설명과 이를 선택한 사용자의 성향 (2-3문장) 예: "샌달우드의 깊고 따뜻한 잔향이 오래도록 머물며 안정감을 줍니다. 이런 향을 선택하는 당신은 차분하고 신뢰할 수 있는 성격의 소유자입니다." -5. **향기로 해석한 당신의 성향 (tendency)**: 3개 노트가 비율을 반영해서 합쳐진 향기를 선택한 사용자의 전체적인 성향 분석 - - 사용자가 어떤 사람일지 추측/해석 (2-3문장) - - 마지막 문장은 반드시: "당신은 사람들에게 '[특성]한 사람'으로 기억됩니다." - 예: "복합적이고 다층적인 매력을 가진 당신은 첫인상은 밝고 활기차지만, 깊이 알수록 더욱 매력적인 면을 발견하게 됩니다. 당신은 사람들에게 '기분 좋은 여운이 오래 남는 사람'으로 기억됩니다." +5. **향기로 해석한 당신의 성향 (tendency)**: 3개 노트가 비율을 반영해서 합쳐진 향기를 선택한 사용자의 전체적인 성향 분석 (2-3문장) + 예: "복합적이고 다층적인 매력을 가진 당신은 첫인상은 밝고 활기차지만, 깊이 알수록 더욱 매력적인 면을 발견하게 됩니다." + +6. **기억되는 모습 (remembered)**: 사람들이 당신을 어떻게 기억할지에 대한 표현 + - 반드시: "당신은 사람들에게 '[특성]한 사람'으로 기억됩니다." 형식 준수 + 예: "당신은 사람들에게 '기분 좋은 여운이 오래 남는 사람'으로 기억됩니다." 출력 형식 (이 형식을 절대 벗어나지 마): @@ -34,12 +36,13 @@ 향기의 첫인상: {탑 노트 설명 + 사용자 성향} 중심을 잡는 향: {미들 노트 설명 + 사용자 성향} 마지막에 남는 잔향: {베이스 노트 설명 + 사용자 성향} -향기로 해석한 당신의 성향: {전체 성향 분석 + 마지막 기억되는 모습} +향기로 해석한 당신의 성향: {전체 성향 분석} +기억되는 모습: 당신은 사람들에게 '[특성]한 사람'으로 기억됩니다. 주의 사항: -1. **반드시 5개 항목 모두 생성할 것** +1. **반드시 6개 항목 모두 생성할 것** 2. **지정된 출력 형식만 사용하고, 그 외 설명은 절대 하지 마** 3. **각 노트의 용량 비율을 고려하여 분석할 것** 4. **친근하고 감성적인 톤으로 작성** 5. **해시태그는 정확히 # 기호로 시작하고 10글자 이내로 작성** -6. **마지막 성향 분석의 끝 문장은 반드시 "당신은 사람들에게 '[특성]한 사람'으로 기억됩니다." 형식 준수** \ No newline at end of file +6. **"기억되는 모습"은 반드시 "당신은 사람들에게 '[특성]한 사람'으로 기억됩니다." 형식 준수** \ No newline at end of file From 21d8685b80ad119872523d7a0e4b02c15db66830 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sat, 2 Aug 2025 21:03:10 +0900 Subject: [PATCH 307/339] =?UTF-8?q?[refactor]=20=ED=96=A5=EC=88=98?= =?UTF-8?q?=EA=B3=B5=EB=B0=A9=20ERROR=20STATUS=20=EC=88=98=EC=A0=95(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/apiPayload/code/status/ErrorStatus.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index a91b440..99941bf 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -99,11 +99,11 @@ public enum ErrorStatus implements BaseErrorCode { INVALID_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST, "S3IMAGE4001", "지원하지 않은 파일 확장자입니다."), // 향수공방 에러 - WORKSHOP_TOTAL_VOLUME_OVERFLOW(HttpStatus.BAD_REQUEST, "WORKSHOP4002", "선택한 노트들의 총 용량은 10 초과할 수 없습니다."), - EXPIRED_WORKSHOP_RESULT(HttpStatus.BAD_REQUEST, "WORKSHOP4003", "향수공방 미리보기 결과가 만료되었습니다. 다시 시도해주세요."), + WORKSHOP_TOTAL_VOLUME_OVERFLOW(HttpStatus.BAD_REQUEST, "WORKSHOP4001", "선택한 노트들의 총 용량은 10 초과할 수 없습니다."), + EXPIRED_WORKSHOP_RESULT(HttpStatus.BAD_REQUEST, "WORKSHOP4002", "향수공방 미리보기 결과가 만료되었습니다. 다시 시도해주세요."), + WORKSHOP_NAME_DUPLICATE(HttpStatus.BAD_REQUEST, "WORKSHOP4003", "이미 같은 이름으로 저장된 향수공방 결과가 있습니다."), WORKSHOP_USER_NOT_MATCH(HttpStatus.BAD_REQUEST, "WORKSHOP4004", "해당 향수공방 결과에 접근할 수 없습니다."), WORKSHOP_ID_NULL(HttpStatus.UNAUTHORIZED, "WORKSHOP4005", "해당 향수공방 결과 정보가 존재하지 않습니다."), - WORKSHOP_NAME_DUPLICATE(HttpStatus.BAD_REQUEST, "WORKSHOP4006", "이미 같은 이름으로 저장된 향수공방 결과가 있습니다."), // 예시,,, ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); From a8fa916db11935818f602abe980fdfa60bea685c Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sat, 2 Aug 2025 21:04:05 +0900 Subject: [PATCH 308/339] =?UTF-8?q?[refactor]=20=ED=96=A5=EC=88=98?= =?UTF-8?q?=EA=B3=B5=EB=B0=A9=20=EC=83=81=EC=84=B8=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=B0=98=ED=99=98=EA=B0=92=20=EC=A0=80=EC=9E=A5=EB=90=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84,=20=EB=85=B8=ED=8A=B8=EA=B0=92,=20=EB=85=B8?= =?UTF-8?q?=ED=8A=B8=EC=96=91=20=EC=B6=94=EA=B0=80(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/converter/WorkshopConverter.java | 35 +++++-------------- .../service/workshop/WorkshopService.java | 1 + .../web/dto/workshop/WorkshopResponseDTO.java | 21 +++++++++++ 3 files changed, 31 insertions(+), 26 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java b/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java index 9e643d2..1c06cd2 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java @@ -30,12 +30,20 @@ public static List toWorkshopListRe .toList(); } + /** 향수공방 상세조회 converter*/ public static WorkshopResponseDTO.WorkshopDetailResponseDTO toWorkshopDetailResponse(Workshop workshop) { // 추천 향수 리스트 JSON 파싱 List recommendedFragranceDTOList = parseFragranceJson(workshop.getRecommendedFragranceJson()); return WorkshopResponseDTO.WorkshopDetailResponseDTO.builder() + .savedName(workshop.getSavedName()) + .topNote(workshop.getTopNote()) + .topNoteVolume(workshop.getTopNoteVolume()) + .middleNote(workshop.getMiddleNote()) + .middleNoteVolume(workshop.getMiddleNoteVolume()) + .baseNote(workshop.getBaseNote()) + .baseNoteVolume(workshop.getBaseNoteVolume()) .keywordSummary(workshop.getKeywordSummary()) .firstImpression(workshop.getFirstImpression()) .centerImpression(workshop.getCenterImpression()) @@ -46,31 +54,6 @@ public static WorkshopResponseDTO.WorkshopDetailResponseDTO toWorkshopDetailResp .build(); } - /** 향수공방 미리보기 응답 DTO 생성 */ - public static WorkshopResponseDTO.WorkshopPreviewResponseDTO toWorkshopPreviewResponse( - WorkshopRequestDTO.WorkshopPreviewRequestDTO request, - WorkshopResult workshopResult - ) { - // TODO: 향수 추천 로직은 FastAPI 연동 후 구현 예정 - List emptyRecommendations = new ArrayList<>(); - - return WorkshopResponseDTO.WorkshopPreviewResponseDTO.builder() - .topNote(request.getTopNote()) - .topNoteVolume(request.getTopNoteVolume()) - .middleNote(request.getMiddleNote()) - .middleNoteVolume(request.getMiddleNoteVolume()) - .baseNote(request.getBaseNote()) - .baseNoteVolume(request.getBaseNoteVolume()) - .keywordSummary(workshopResult.getKeywordSummary()) - .firstImpression(workshopResult.getFirstImpression()) - .centerImpression(workshopResult.getCenterImpression()) - .lastImpression(workshopResult.getLastImpression()) - .tendency(workshopResult.getTendency()) - .remembered(workshopResult.getRemembered()) - .recommendedFragranceJson(emptyRecommendations) - .build(); - } - /** 향수공방 미리보기 응답 DTO 생성 (추천 향수 포함) */ public static WorkshopResponseDTO.WorkshopPreviewResponseDTO toWorkshopPreviewResponse( WorkshopRequestDTO.WorkshopPreviewRequestDTO request, @@ -78,7 +61,7 @@ public static WorkshopResponseDTO.WorkshopPreviewResponseDTO toWorkshopPreviewRe List recommendedFragrances ) { // WorkshopFragrance를 RecommendedFragranceDTO로 변환 - List recommendedFragranceDTOList = + List recommendedFragranceDTOList = recommendedFragrances.stream() .map(fragrance -> WorkshopResponseDTO.RecommendedFragranceDTO.builder() .brand(fragrance.getBrand()) diff --git a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java b/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java index c370dc7..b8179f5 100644 --- a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java +++ b/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java @@ -107,6 +107,7 @@ public WorkshopResponseDTO.WorkshopSaveResponseDTO saveWorkshop( return WorkshopConverter.toWorkshopSaveResponse(savedWorkshop); } + /**향수공방 결과 상세조회 서비스*/ @Transactional(readOnly = true) public WorkshopResponseDTO.WorkshopDetailResponseDTO findWorkshopById( Long workshopId, CustomUserDetails userDetails) { diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java index 947e898..1c132ef 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java @@ -36,6 +36,27 @@ public static class WorkshopListResponseDTO { @Schema(description = "향수공방 결과 상세조회") public static class WorkshopDetailResponseDTO { + @Schema(description = "저장된 향수공방 이름", example = "향수공방 해봤는데 좋은거1") + private String savedName; + + @Schema(description = "탑 노트", example = "베르가못") + private String topNote; + + @Schema(description = "탑 노트 용량", example = "3") + private Long topNoteVolume; + + @Schema(description = "미들 노트", example = "장미") + private String middleNote; + + @Schema(description = "미들 노트 용량", example = "4") + private Long middleNoteVolume; + + @Schema(description = "베이스 노트", example = "바닐라") + private String baseNote; + + @Schema(description = "베이스 노트 용량", example = "3") + private Long baseNoteVolume; + @Schema(description = "시각적 키워드 (해시태그 형태)", example = "#상큼한첫인상 #감성적중심 #우디잔향\n#깊이있는사람 #신뢰감있는향기") private String keywordSummary; From d32fe09b4b61e40aef2f00b75e8ce6a88b486a5b Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sat, 2 Aug 2025 21:31:13 +0900 Subject: [PATCH 309/339] =?UTF-8?q?[refactor]=20=ED=96=A5=EC=88=98?= =?UTF-8?q?=EA=B3=B5=EB=B0=A9=20=EC=8A=A4=EC=9B=A8=EA=B1=B0=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=9D=91=EB=8B=B5=EA=B0=92=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../docs/workshop/WorkshopControllerDocs.java | 96 +++++++++++++++++-- 1 file changed, 88 insertions(+), 8 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/web/docs/workshop/WorkshopControllerDocs.java b/src/main/java/PerfumeOnMe/spring/web/docs/workshop/WorkshopControllerDocs.java index 296e4a0..62a3c52 100644 --- a/src/main/java/PerfumeOnMe/spring/web/docs/workshop/WorkshopControllerDocs.java +++ b/src/main/java/PerfumeOnMe/spring/web/docs/workshop/WorkshopControllerDocs.java @@ -36,11 +36,31 @@ public interface WorkshopControllerDocs { ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "COMMON401", - description = "인증이 필요합니다. 액세스 토큰을 입력해주세요." + description = "인증이 필요합니다. 액세스 토큰을 입력해주세요.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "COMMON401", + "message": "인증이 필요합니다." + } + """) + ) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "WORKSHOP4002", - description = "선택한 노트들의 총 용량은 10을 초과할 수 없습니다." + description = "선택한 노트들의 총 용량은 10을 초과할 수 없습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "WORKSHOP4001", + "message": "선택한 노트들의 총 용량은 10 초과할 수 없습니다." + } + """) + ) ) } ) @@ -66,15 +86,45 @@ ResponseEntity> getW ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "COMMON401", - description = "인증이 필요합니다. 액세스 토큰을 입력해주세요." + description = "인증이 필요합니다. 액세스 토큰을 입력해주세요.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "COMMON401", + "message": "인증이 필요합니다." + } + """) + ) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "WORKSHOP4003", - description = "향수공방 미리보기 결과가 만료되었습니다. 다시 시도해주세요." + description = "향수공방 미리보기 결과가 만료되었습니다. 다시 시도해주세요.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "WORKSHOP4002", + "message": "향수공방 미리보기 결과가 만료되었습니다. 다시 시도해주세요." + } + """) + ) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "WORKSHOP4006", - description = "이미 같은 이름으로 저장된 향수공방 결과가 있습니다." + description = "이미 같은 이름으로 저장된 향수공방 결과가 있습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "WORKSHOP4003", + "message": "이미 같은 이름으로 저장된 향수공방 결과가 있습니다." + } + """) + ) ) } ) @@ -118,15 +168,45 @@ ResponseEntity>> g ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "COMMON401", - description = "인증이 필요합니다. 액세스 토큰을 입력해주세요." + description = "인증이 필요합니다. 액세스 토큰을 입력해주세요.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "COMMON401", + "message": "인증이 필요합니다." + } + """) + ) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "WORKSHOP4004", - description = "해당 향수공방 결과에 접근할 수 없습니다." + description = "해당 향수공방 결과에 접근할 수 없습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "WORKSHOP4004", + "message": "해당 향수공방 결과에 접근할 수 없습니다." + } + """) + ) ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "WORKSHOP4005", - description = "해당 향수공방 결과 정보가 존재하지 않습니다." + description = "해당 향수공방 결과 정보가 존재하지 않습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "WORKSHOP4005", + "message": "해당 향수공방 결과 정보가 존재하지 않습니다." + } + """) + ) ) } ) From f9f1afceed54613ab1ee15c4fbc085b7fe2ba427 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sat, 2 Aug 2025 22:06:06 +0900 Subject: [PATCH 310/339] =?UTF-8?q?[refactor]=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../apiPayload/code/status/ErrorStatus.java | 12 +- .../imagekeyword/ImageKeywordServiceImpl.java | 7 + .../ImageKeywordControllerDocs.java | 169 ++++++++++++++++-- 3 files changed, 166 insertions(+), 22 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index 99941bf..a94b081 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -67,12 +67,6 @@ public enum ErrorStatus implements BaseErrorCode { PROMPT_LOADING_FAIL(HttpStatus.BAD_REQUEST, "CHATBOT4002", "프롬프트 로딩에 실패하였습니다."), REQUIRED_MESSAGES(HttpStatus.BAD_REQUEST, "CHATBOT4003", "메세지를 입력하세요."), - // 이미지 키워드 에러 - INVALID_IMAGEKEYWORD_VALUE(HttpStatus.BAD_REQUEST, "IMAGEKEYWORD4001", "잘못된 키워드값입니다. 키워드 값을 확인해주세요."), - EXPIRED_IMAGEKEYWORD_RESULT(HttpStatus.REQUEST_TIMEOUT, "IMAGEKEYWORD4002", "생성할 이미지 키워드 결과가 만료되었습니다."), - ALREADY_KEYWORD_NAME(HttpStatus.BAD_REQUEST, "IMAGEKEYWORD4003", "동일한 이름으로 저장된 결과가 존재합니다."), - INVALID_IMAGEKEYWORD_ID(HttpStatus.BAD_REQUEST, "IMAGEKEYWORD4004", "해당 ID의 이미지 결과를 조회할 수 없습니다."), - // FastAPI 연동 에러 FASTAPI_COMMUNICATION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "FASTAPI5001", "FastAPI 서버 통신 중 오류가 발생했습니다."), @@ -98,6 +92,12 @@ public enum ErrorStatus implements BaseErrorCode { // S3 에러 INVALID_IMAGE_EXTENSION(HttpStatus.BAD_REQUEST, "S3IMAGE4001", "지원하지 않은 파일 확장자입니다."), + // 이미지 키워드 에러 + EXPIRED_IMAGEKEYWORD_RESULT(HttpStatus.REQUEST_TIMEOUT, "IMAGEKEYWORD4001", "이미지 키워드 미리보기 결과가 만료되었습니다. 다시 시도해주세요."), + ALREADY_KEYWORD_NAME(HttpStatus.BAD_REQUEST, "IMAGEKEYWORD4002", "이미 같은 이름으로 저장된 이미지 키워드 결과가 있습니다."), + IMAGEKEYWORD_ID_NULL(HttpStatus.BAD_REQUEST, "IMAGEKEYWORD4003", "해당 이미지 키워드 결과 정보가 존재하지 않습니다."), + INVALID_IMAGEKEYWORD_ID(HttpStatus.BAD_REQUEST, "IMAGEKEYWORD4004", "해당 이미지 키워드 결과에 접근할 수 없습니다."), + // 향수공방 에러 WORKSHOP_TOTAL_VOLUME_OVERFLOW(HttpStatus.BAD_REQUEST, "WORKSHOP4001", "선택한 노트들의 총 용량은 10 초과할 수 없습니다."), EXPIRED_WORKSHOP_RESULT(HttpStatus.BAD_REQUEST, "WORKSHOP4002", "향수공방 미리보기 결과가 만료되었습니다. 다시 시도해주세요."), diff --git a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java index 046dded..52ad5b9 100644 --- a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java @@ -46,8 +46,15 @@ public ImageKeywordResponseDTO.ImageKeywordDetailResponseDTO getImageKeywordDeta User user = userRepository.findById(userId) .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + // 이미지 키워드 결과 존재 여부 검증 + if (!imageKeywordRepository.existsById(imageKeywordId)) { + throw new GeneralException(ErrorStatus.IMAGEKEYWORD_ID_NULL); + } + + // 사용자의 이미지 키워드 결과 여부 검증 ImageKeyword keyword = imageKeywordRepository.findByIdAndUser(imageKeywordId, user) .orElseThrow(() -> new GeneralException(ErrorStatus.INVALID_IMAGEKEYWORD_ID)); + return ImageKeywordConverter.toImageKeywordDetailResponse(keyword, imageKeywordDescriptionRepository); } diff --git a/src/main/java/PerfumeOnMe/spring/web/docs/imagekeyword/ImageKeywordControllerDocs.java b/src/main/java/PerfumeOnMe/spring/web/docs/imagekeyword/ImageKeywordControllerDocs.java index 5ec7421..65b10ae 100644 --- a/src/main/java/PerfumeOnMe/spring/web/docs/imagekeyword/ImageKeywordControllerDocs.java +++ b/src/main/java/PerfumeOnMe/spring/web/docs/imagekeyword/ImageKeywordControllerDocs.java @@ -35,7 +35,17 @@ public interface ImageKeywordControllerDocs { ), @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "COMMON401", - description = "인증이 필요합니다. 액세스 토큰을 입력해주세요." + description = "인증이 필요합니다. 액세스 토큰을 입력해주세요.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "COMMON401", + "message": "인증이 필요합니다." + } + """) + ) ) } ) @@ -46,51 +56,178 @@ ResponseEntity> getImageKeywordDetail( + @Parameter(description = "조회할 이미지 키워드 결과 ID", required = true, example = "1") @PathVariable Long imageKeywordId, + @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails ); @Operation( summary = "이미지 키워드 결과 미리보기", - description = "5가지 키워드를 기반으로 감성 시나리오 및 향수 추천 결과를 생성하여 미리 확인합니다.", + description = "사용자가 선택한 5가지 키워드(분위기, 스타일, 성별, 계절, 성격)를 바탕으로 감성 시나리오 및 향수 추천 결과를 생성하여 미리 확인합니다. " + + "결과는 Redis에 15분간 임시 저장되며, 이미지 키워드 저장 API 호출 시 활용됩니다.", responses = { - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다."), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON400", description = "잘못된 요청입니다."), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON401", description = "인증이 필요합니다.") + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON200", + description = "요청에 성공하였습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ImageKeywordResponseDTO.ImageKeywordPreviewResponseDTO.class) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON400", + description = "잘못된 요청입니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "COMMON400", + "message": "잘못된 요청입니다." + } + """) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON401", + description = "인증이 필요합니다. 액세스 토큰을 입력해주세요.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "COMMON401", + "message": "인증이 필요합니다." + } + """) + ) + ) } ) ResponseEntity> getImageKeywordPreview( @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails, + @Parameter(description = "이미지 키워드 미리보기 생성 요청", required = true) @Validated @RequestBody ImageKeywordRequestDTO.ImageKeywordPreviewRequestDTO request ); @Operation( summary = "이미지 키워드 결과 저장", - description = """ - 프리뷰 확인 후 이름을 지정해 최종 저장합니다. - Redis에 임시 저장된 키워드를 기반으로 저장되며, 완료 후 해당 Redis 키는 삭제됩니다. - """, + description = "사용자가 미리보기에서 확인한 이미지 키워드 결과를 지정한 이름으로 데이터베이스에 영구 저장합니다. " + + "Redis에 임시 저장된 미리보기 데이터를 사용하므로, 미리보기 생성 후 15분 이내에 호출해야 합니다.", responses = { @io.swagger.v3.oas.annotations.responses.ApiResponse( responseCode = "COMMON200", - description = "성공입니다.", - content = @Content(schema = @Schema(implementation = ImageKeywordResponseDTO.ImageKeywordSaveResponseDTO.class)) + description = "요청에 성공하였습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ImageKeywordResponseDTO.ImageKeywordSaveResponseDTO.class) + ) ), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "IK4002", description = "생성한 이미지 키워드 결과가 만료되었습니다."), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "IK4003", description = "이미 동일한 이름으로 저장된 결과가 존재합니다.") + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON401", + description = "인증이 필요합니다. 액세스 토큰을 입력해주세요.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "COMMON401", + "message": "인증이 필요합니다." + } + """) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "IMAGEKEYWORD4001", + description = "이미지 키워드 미리보기 결과가 만료되었습니다. 다시 시도해주세요.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "IMAGEKEYWORD4001", + "message": "이미지 키워드 미리보기 결과가 만료되었습니다. 다시 시도해주세요." + } + """) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "IMAGEKEYWORD4002", + description = "이미 같은 이름으로 저장된 이미지 키워드 결과가 있습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "IMAGEKEYWORD4002", + "message": "이미 같은 이름으로 저장된 이미지 키워드 결과가 있습니다." + } + """) + ) + ) } ) ResponseEntity> saveImageKeywordResult( @Parameter(hidden = true) @AuthenticationPrincipal CustomUserDetails userDetails, + @Parameter(description = "이미지 키워드 저장 요청", required = true) @Validated @RequestBody ImageKeywordRequestDTO.ImageKeywordSaveRequestDTO request ); } \ No newline at end of file From 4f2719cd5feaaa69e4c3dfa716d366559e3dcad3 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 3 Aug 2025 17:19:08 +0900 Subject: [PATCH 311/339] =?UTF-8?q?[feature]=20=EB=82=98=EB=A7=8C=EC=9D=98?= =?UTF-8?q?=20=ED=96=A5=EC=88=98=20Controller,=20DTO=20=EA=B5=AC=ED=98=84(?= =?UTF-8?q?#123)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/converter/FragranceConverter.java | 9 +++++++++ .../web/controller/FragranceController.java | 14 +++++++++++++ .../dto/fragrance/FragranceResponseDTO.java | 20 +++++++++++++++++++ 3 files changed, 43 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java index c9ade07..8ab1e22 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java @@ -169,4 +169,13 @@ public static FragranceResponseDTO.FavoriteCancelResponseDTO toFavoriteCancelRes .build(); } + // 메인페이지 나만의 향수 결과 변환 + public static FragranceResponseDTO.FragranceMyPerfumeResult toMyPerfumeResult( + boolean exists, List myPerfumeList) { + return FragranceResponseDTO.FragranceMyPerfumeResult.builder() + .exists(exists) + .myPerfumeList(myPerfumeList) + .build(); + } + } diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java index 0b7caa0..30c7ccd 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java +++ b/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java @@ -201,4 +201,18 @@ public ResponseEntity> FragranceResponseDTO.FragranceMdChoiceResult result = fragranceService.getFragranceMdChoice(userDetails); return ResponseEntity.ok(ApiResponse.onSuccess(result)); } + + @GetMapping("/my-perfume") + @Operation( + summary = "메인페이지 나만의 향수 조회 API", + description = "이미지키워드나 향수공방 중 가장 최근 결과에서 추천향수를 반환하는 API입니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200", description = "성공입니다", content = @Content(mediaType = "application/json", schema = @Schema(implementation = FragranceResponseDTO.FragranceMyPerfumeResult.class))), + } + ) + public ResponseEntity> getFragrancesMyPerfume( + @AuthenticationPrincipal CustomUserDetails userDetails) { + FragranceResponseDTO.FragranceMyPerfumeResult result = fragranceService.getFragranceMyPerfume(userDetails); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } } diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java index 6b1f3de..428a4d2 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java @@ -123,6 +123,26 @@ public static class FragranceMdChoiceResult { private String nickname; } + // 메인페이지 나만의 향수 DTO + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class FragranceMyPerfumeResult { + private boolean exists; + private List myPerfumeList; + } + + // 나만의 향수 + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class MyPerfume { + private String brand; + private String name; + private String imageUrl; + } } From 4888e7b52c6c8d38bc7562b8300da6adc0c1bc70 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 3 Aug 2025 17:28:35 +0900 Subject: [PATCH 312/339] =?UTF-8?q?[feature]=20=EB=82=98=EB=A7=8C=EC=9D=98?= =?UTF-8?q?=20=ED=96=A5=EC=88=98=20=EC=84=9C=EB=B9=84=EC=8A=A4=EA=B5=AC?= =?UTF-8?q?=ED=98=84(#123)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../imagekeyword/ImageKeywordRepository.java | 2 + .../workshop/WorkshopRepository.java | 2 + .../service/fragrance/FragranceService.java | 3 + .../fragrance/FragranceServiceImpl.java | 77 +++++++++++++++++++ 4 files changed, 84 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/repository/imagekeyword/ImageKeywordRepository.java b/src/main/java/PerfumeOnMe/spring/repository/imagekeyword/ImageKeywordRepository.java index 53a6cc0..7726f7d 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/imagekeyword/ImageKeywordRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/imagekeyword/ImageKeywordRepository.java @@ -15,4 +15,6 @@ public interface ImageKeywordRepository extends JpaRepository findFirstByUserOrderByCreatedAtDesc(User user); } diff --git a/src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopRepository.java b/src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopRepository.java index b9f9d0d..5eed63d 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopRepository.java +++ b/src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopRepository.java @@ -15,4 +15,6 @@ public interface WorkshopRepository extends JpaRepository { Optional findByIdAndUser(Long id, User user); boolean existsByUserAndSavedName(User user, String savedName); + + Optional findFirstByUserOrderByCreatedAtDesc(User user); } diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java index ac603ef..7f1cc7c 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java @@ -27,5 +27,8 @@ FragranceResponseDTO.FragranceSearchFinalResult getFragranceListAll(FragranceReq Long userId); FragranceResponseDTO.FragranceMdChoiceResult getFragranceMdChoice(CustomUserDetails userDetails); + + // 향수 메인페이지 나만의 향수 조회 API + FragranceResponseDTO.FragranceMyPerfumeResult getFragranceMyPerfume(CustomUserDetails userDetails); } diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java index 1992eee..8e9b5a4 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java @@ -1,6 +1,7 @@ package PerfumeOnMe.spring.service.fragrance; import java.util.List; +import java.util.Optional; import java.util.stream.Collectors; import org.springframework.data.domain.Page; @@ -10,22 +11,29 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; import PerfumeOnMe.spring.converter.FragranceConverter; import PerfumeOnMe.spring.domain.Fragrance; +import PerfumeOnMe.spring.domain.ImageKeyword; import PerfumeOnMe.spring.domain.User; +import PerfumeOnMe.spring.domain.Workshop; import PerfumeOnMe.spring.domain.enums.FragranceGender; import PerfumeOnMe.spring.domain.enums.FragranceType; import PerfumeOnMe.spring.domain.enums.UserGender; import PerfumeOnMe.spring.domain.mapping.UserFragrance; import PerfumeOnMe.spring.repository.fragrance.FragranceRepository; +import PerfumeOnMe.spring.repository.imagekeyword.ImageKeywordRepository; import PerfumeOnMe.spring.repository.location.LocationRepository; import PerfumeOnMe.spring.repository.note.NoteRepository; import PerfumeOnMe.spring.repository.season.SeasonRepository; import PerfumeOnMe.spring.repository.user.UserRepository; import PerfumeOnMe.spring.repository.userFragrance.UserFragranceRepository; +import PerfumeOnMe.spring.repository.workshop.WorkshopRepository; import PerfumeOnMe.spring.web.dto.fragrance.FragranceRequestDTO; import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; import lombok.RequiredArgsConstructor; @@ -41,6 +49,9 @@ public class FragranceServiceImpl implements FragranceService { private final NoteRepository noteRepository; private final SeasonRepository seasonRepository; private final LocationRepository locationRepository; + private final WorkshopRepository workshopRepository; + private final ImageKeywordRepository imageKeywordRepository; + private final ObjectMapper objectMapper; // 향수 상세 API @Override @@ -210,4 +221,70 @@ private FragranceResponseDTO.FragranceMdChoiceResult getFragranceMdChoiceFinalRe return FragranceConverter.toMdChoiceResult(content, name, nickname); } + + // 메인페이지 나만의 향수 조회 API + @Override + public FragranceResponseDTO.FragranceMyPerfumeResult getFragranceMyPerfume(CustomUserDetails userDetails) { + // 사용자 조회 + User user = userRepository.findUserByLoginId(userDetails.getUsername()) + .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + + Optional workshop = workshopRepository.findFirstByUserOrderByCreatedAtDesc(user); + Optional imageKeyword = imageKeywordRepository.findFirstByUserOrderByCreatedAtDesc(user); + + // 두 결과 모두 없는 경우 + if (workshop.isEmpty() && imageKeyword.isEmpty()) { + return FragranceConverter.toMyPerfumeResult(false, List.of()); + } + + String selectedJson = null; + + // 더 최신 결과 선택 + if (workshop.isPresent() && imageKeyword.isPresent()) { + // 두 결과 모두 있는 경우 createdAt 비교하여 최신 선택 + if (workshop.get().getCreatedAt().isAfter(imageKeyword.get().getCreatedAt())) { + selectedJson = workshop.get().getRecommendedFragranceJson(); + } else { + selectedJson = imageKeyword.get().getRecommendedFragranceJson(); + } + } else if (workshop.isPresent()) { + // 향수공방 결과만 있는 경우 + selectedJson = workshop.get().getRecommendedFragranceJson(); + } else { + // 이미지키워드 결과만 있는 경우 + selectedJson = imageKeyword.get().getRecommendedFragranceJson(); + } + + // JSON 파싱하여 MyPerfume 리스트로 변환 + List myPerfumeList = parseRecommendedFragranceJson(selectedJson); + + return FragranceConverter.toMyPerfumeResult(true, myPerfumeList); + } + + /**recommendedFragranceJson 필드를 파싱하여 MyPerfume 리스트로 변환*/ + private List parseRecommendedFragranceJson(String jsonString) { + try { + if (jsonString == null || jsonString.trim().isEmpty()) { + return List.of(); + } + + // JSON 배열을 파싱하여 MyPerfume DTO로 변환 + TypeReference> typeRef = + new TypeReference>() { + }; + + List perfumeList = objectMapper.readValue(jsonString, typeRef); + + // null 체크 및 필수 필드 검증 + return perfumeList.stream() + .filter(perfume -> perfume != null && + perfume.getBrand() != null && + perfume.getName() != null) + .collect(Collectors.toList()); + + } catch (Exception e) { + // JSON 파싱 실패 시 빈 리스트 반환 + return List.of(); + } + } } From 391dfd3701a3f2a32a454469802ee192c1609c22 Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Sun, 3 Aug 2025 18:15:06 +0900 Subject: [PATCH 313/339] =?UTF-8?q?[Refactor]=20=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=A4=91=EB=B3=B5=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=EB=A1=9C=20=EB=8C=80=EC=B2=B4=20(#123)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../fragrance/FragranceServiceImpl.java | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java index 8e9b5a4..2e42939 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java @@ -79,8 +79,7 @@ public FragranceResponseDTO.FragranceSearchFinalResult searchFragrances( @Override @Transactional(readOnly = false) public FragranceResponseDTO.FavoriteResponseDTO addFavorite(Long userId, Long fragranceId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + User user = findUserById(userId); Fragrance fragrance = fragranceRepository.findById(fragranceId) .orElseThrow(() -> new GeneralException(ErrorStatus.FRAGRANCE_NOT_FOUND)); @@ -101,8 +100,7 @@ public FragranceResponseDTO.FavoriteResponseDTO addFavorite(Long userId, Long fr @Override @Transactional(readOnly = false) public FragranceResponseDTO.FavoriteCancelResponseDTO deleteFavorite(Long userId, Long fragranceId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + User user = findUserById(userId); Fragrance fragrance = fragranceRepository.findById(fragranceId) .orElseThrow(() -> new GeneralException(ErrorStatus.FRAGRANCE_NOT_FOUND)); @@ -192,8 +190,7 @@ private FragranceResponseDTO.FragranceSearchFinalResult getFragranceSearchFinalR public FragranceResponseDTO.FragranceMdChoiceResult getFragranceMdChoice(CustomUserDetails userDetails) { // 사용자 조회 - User user = userRepository.findUserByLoginId(userDetails.getUsername()) - .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + User user = findUserById(userDetails.getUserId()); // 사용자 선호 향 조회 List noteList = user.getUserNoteList().stream() @@ -226,8 +223,7 @@ private FragranceResponseDTO.FragranceMdChoiceResult getFragranceMdChoiceFinalRe @Override public FragranceResponseDTO.FragranceMyPerfumeResult getFragranceMyPerfume(CustomUserDetails userDetails) { // 사용자 조회 - User user = userRepository.findUserByLoginId(userDetails.getUsername()) - .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + User user = findUserById(userDetails.getUserId()); Optional workshop = workshopRepository.findFirstByUserOrderByCreatedAtDesc(user); Optional imageKeyword = imageKeywordRepository.findFirstByUserOrderByCreatedAtDesc(user); @@ -287,4 +283,12 @@ private List parseRecommendedFragranceJson(Strin return List.of(); } } + + /** + * 사용자 ID로 사용자 조회 (공통 메서드) + */ + private User findUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + } } From e964fb4554b4c544234c260477326349e827d78f Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Thu, 7 Aug 2025 20:18:59 +0900 Subject: [PATCH 314/339] =?UTF-8?q?[Hotfix]=20application-dev=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 3796258..e70eeac 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -81,8 +81,7 @@ cloud: external: fastapi: recommend-url: ${EXTERNAL_FASTAPI_RECOMMEND_URL:http://perfume-recommender-container:8000/recommend/full} # Docker 컨테이너 간 통신 - pbti-recommend-url: ${EXTERNAL_FASTAPI_PBTI_URL:http://perfume-recommender-container:8000/recommend/pbti} # Docker 컨테이너 간 통신 - + pbti-recommend-url: ${EXTERNAL_FASTAPI_PBTI_URL:http://perfume-recommender-container:8000/pbti} # Docker 컨테이너 간 통신 app: image-keyword: character-image-base-path: ${IMAGE_KEYWORD_CHARACTER_BASE_PATH} From 77ae6862db3bac9c7e7088080407c616d454b8dc Mon Sep 17 00:00:00 2001 From: chanudevelop Date: Thu, 7 Aug 2025 20:21:23 +0900 Subject: [PATCH 315/339] =?UTF-8?q?[Hotfix]=20application-dev=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index e70eeac..649be7f 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -81,7 +81,7 @@ cloud: external: fastapi: recommend-url: ${EXTERNAL_FASTAPI_RECOMMEND_URL:http://perfume-recommender-container:8000/recommend/full} # Docker 컨테이너 간 통신 - pbti-recommend-url: ${EXTERNAL_FASTAPI_PBTI_URL:http://perfume-recommender-container:8000/pbti} # Docker 컨테이너 간 통신 + pbti-recommend-url: ${EXTERNAL_FASTAPI_PBTI_URL:http://perfume-recommender-container:8000/pbti/full-result} # Docker 컨테이너 간 통신 app: image-keyword: character-image-base-path: ${IMAGE_KEYWORD_CHARACTER_BASE_PATH} From 4d67434240c3c320378e1dcf4a694ece46cbe3d2 Mon Sep 17 00:00:00 2001 From: bulee5328 Date: Thu, 7 Aug 2025 21:47:41 +0900 Subject: [PATCH 316/339] =?UTF-8?q?[Fix]=20OpenAI=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=82=AD=EC=A0=9C=20(#98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/external/FastApiClient.java | 10 +-- .../service/openAi/OpenAiApiClient.java | 56 ------------- .../spring/service/openAi/OpenAiService.java | 8 -- .../service/openAi/OpenAiServiceImpl.java | 19 ----- .../spring/service/openAi/PromptBuilder.java | 76 ------------------ .../spring/service/pbti/PbtiScoringUtil.java | 74 ----------------- .../spring/service/pbti/PbtiServiceImpl.java | 80 +++++++++++-------- .../FastApiPbtiRecommendResponse.java | 50 ++++++++++++ 8 files changed, 101 insertions(+), 272 deletions(-) delete mode 100644 src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiApiClient.java delete mode 100644 src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiService.java delete mode 100644 src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiServiceImpl.java delete mode 100644 src/main/java/PerfumeOnMe/spring/service/openAi/PromptBuilder.java delete mode 100644 src/main/java/PerfumeOnMe/spring/service/pbti/PbtiScoringUtil.java diff --git a/src/main/java/PerfumeOnMe/spring/service/external/FastApiClient.java b/src/main/java/PerfumeOnMe/spring/service/external/FastApiClient.java index d9709f0..2155a97 100644 --- a/src/main/java/PerfumeOnMe/spring/service/external/FastApiClient.java +++ b/src/main/java/PerfumeOnMe/spring/service/external/FastApiClient.java @@ -9,6 +9,8 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; import PerfumeOnMe.spring.web.dto.external.FastApiPbtiRecommendResponse; import PerfumeOnMe.spring.web.dto.external.FastApiRecommendRequest; import PerfumeOnMe.spring.web.dto.external.FastApiRecommendResponse; @@ -39,12 +41,11 @@ public FastApiRecommendResponse getFullRecommendation(FastApiRecommendRequest re return response.getBody(); } catch (Exception e) { - // throw new GeneralException(ErrorStatus.FASTAPI_COMMUNICATION_ERROR); - return new FastApiRecommendResponse(); //임시조치 : FAST API 서버 없을 때 기본응답반환으로 서버 유지 + throw new GeneralException(ErrorStatus.FASTAPI_COMMUNICATION_ERROR); } } - public FastApiPbtiRecommendResponse getPbtiRecommendation(FastApiRecommendRequest.PbtiRequest request) { + public FastApiPbtiRecommendResponse getFullPbtiResult(FastApiRecommendRequest.PbtiRequest request) { try { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); @@ -57,8 +58,7 @@ public FastApiPbtiRecommendResponse getPbtiRecommendation(FastApiRecommendReques return response.getBody(); } catch (Exception e) { - // throw new GeneralException(ErrorStatus.FASTAPI_COMMUNICATION_ERROR); - return new FastApiPbtiRecommendResponse(); //임시조치 : FAST API 서버 없을 때 기본응답반환으로 서버 유지 + throw new GeneralException(ErrorStatus.FASTAPI_COMMUNICATION_ERROR); } } diff --git a/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiApiClient.java b/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiApiClient.java deleted file mode 100644 index b72ec7e..0000000 --- a/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiApiClient.java +++ /dev/null @@ -1,56 +0,0 @@ -package PerfumeOnMe.spring.service.openAi; - -import java.util.List; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Component; -import org.springframework.web.reactive.function.client.WebClient; - -import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; -import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.web.dto.Pbti.ChatGptRequest; -import PerfumeOnMe.spring.web.dto.Pbti.ChatGptResponse; -import lombok.RequiredArgsConstructor; -import reactor.core.publisher.Mono; - -@Component -@RequiredArgsConstructor -public class OpenAiApiClient { - - private static final String OPENAI_API_URL = "https://api.openai.com/v1/chat/completions"; - private final WebClient webClient; - - @Value("${openai.api-key}") - private String openAiApiKey; - @Value("${openai.model}") - private String model; // OpenAI 모델 이름 - gpt-4 - - public ChatGptResponse getChatGptResponse(ChatGptRequest request) { - return webClient.post() - .uri(OPENAI_API_URL) - .header(HttpHeaders.AUTHORIZATION, "Bearer " + openAiApiKey) - .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) - .bodyValue(request) - .retrieve() - .bodyToMono(ChatGptResponse.class) - .onErrorResume(e -> Mono.error(new GeneralException(ErrorStatus.CALL_WEBCLIENT_ERROR))) - .block(); - } - - public String callChatGPT(String prompt) { - ChatGptRequest request = ChatGptRequest.builder() - .model(model) - .temperature(0.7) - .messages(List.of( - new ChatGptRequest.Message("user", prompt) - )) - .build(); - - ChatGptResponse response = getChatGptResponse(request); - - return response.getChoices().get(0).getMessage().getContent(); - } - -} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiService.java b/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiService.java deleted file mode 100644 index bfb68dc..0000000 --- a/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiService.java +++ /dev/null @@ -1,8 +0,0 @@ -package PerfumeOnMe.spring.service.openAi; - -public interface OpenAiService { - - // PBTI 구조화된 응답 반환 - String getStructuredResponse(String prompt); -} - diff --git a/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiServiceImpl.java deleted file mode 100644 index 141e49e..0000000 --- a/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiServiceImpl.java +++ /dev/null @@ -1,19 +0,0 @@ -package PerfumeOnMe.spring.service.openAi; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -@Transactional -public class OpenAiServiceImpl implements OpenAiService { - - private final OpenAiApiClient openAiApiClient; // GPT API 호출용 클라이언트 - - @Override - public String getStructuredResponse(String prompt) { - return openAiApiClient.callChatGPT(prompt); // 실제 GPT 호출 로직 구현 - } -} diff --git a/src/main/java/PerfumeOnMe/spring/service/openAi/PromptBuilder.java b/src/main/java/PerfumeOnMe/spring/service/openAi/PromptBuilder.java deleted file mode 100644 index 0ea895b..0000000 --- a/src/main/java/PerfumeOnMe/spring/service/openAi/PromptBuilder.java +++ /dev/null @@ -1,76 +0,0 @@ -package PerfumeOnMe.spring.service.openAi; - -import PerfumeOnMe.spring.web.dto.Pbti.PbtiRequestDTO; -import PerfumeOnMe.spring.web.dto.Pbti.PbtiResponseDTO; - -public class PromptBuilder { - - public static String buildPromptFromRequest(PbtiRequestDTO.PbtiQuestionRequest request, - PbtiResponseDTO.PbtiResult result) { - - String keywordString = String.join(", ", - result.getKeyword1(), - result.getKeyword2(), - result.getKeyword3(), - result.getKeyword4() - ); - - return String.format(""" - 아래는 사용자가 향수 성향 테스트에 응답한 결과입니다.: - Q1: %s - Q2: %s - Q3: %s - Q4: %s - Q5: %s - Q6: %s - Q7: %s - Q8: %s - - 이 사용자는 다음과 같은 향수 성향 키워드를 가지고 있습니다: - %s - - 이 키워드 각각에 대해 향수 성향 기반 설명(keywordDescription)을 작성하세요. 각 키워드는 다음 JSON 형식의 "keywords" 필드에 배열로 포함되어야 합니다. - 또한 각 keywords 배열의 "keyword"는 위의 향수 성향 키워드에서 그대로 사용하세요. GPT가 임의로 바꾸지 마세요. - - 추가로 각 질문에 대한 사용자의 답변을 아래 JSON 포맷에 맞게 분석하여 출력하세요. JSON 이외의 설명은 절대 하지 말고, JSON 데이터만 정확하게 출력해주세요.: - - - "recommendation"은 '당신은' 으로 시작되도록 하세요. - - "perfumeStyle" 내 "notes" 배열의 "category"를 "scentPoint" 내의 "category"에서 사용해주세요. - - "scentPoint" 배열 내의 "category"와 "perfumeStyle" 내 "notes" 배열 내의 "category"는 한국어로 응답해주세요. - - "scentPoint" 배열 내의 "point"는 숫자가 큰 순서대로 출력해주세요. - - "summary"는 사용자의 성격이 반영되는 단어가 들어가도록 간단하게 요약해주세요. ex) “사람들과의 에너지 흐름을 잘 이끌어내는 ~한 사람” - - "keywords" 배열에는 4개 항목을 포함하세요. - - "perfumeStyle" 내 "notes" 배열에는 5개 항목을 포함하세요. - - "scentPoint" 배열에는 5개 항목을 포함하세요. - { - "recommendation": "...", - "keywords": [ - { - "keyword": "...", - "keywordDescription": "..." - } - // 4개 항목 - ], - "perfumeStyle": { - "description": "...", - "notes": [ - { - "category": "...", - "categoryDescription": "..." - } - // 5개 항목 - ] - }, - "scentPoint": [ - { - "category": "...", - "point": 1~6 범위의 정수 중 하나 - } - // 5개 항목 - ], - "summary": "..."x` - } - """, request.getQOne(), request.getQTwo(), request.getQThree(), request.getQFour(), - request.getQFive(), request.getQSix(), request.getQSeven(), request.getQEight(), keywordString); - } -} diff --git a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiScoringUtil.java b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiScoringUtil.java deleted file mode 100644 index 79fc7dd..0000000 --- a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiScoringUtil.java +++ /dev/null @@ -1,74 +0,0 @@ -package PerfumeOnMe.spring.service.pbti; - -import PerfumeOnMe.spring.web.dto.Pbti.PbtiRequestDTO; -import PerfumeOnMe.spring.web.dto.Pbti.PbtiResponseDTO; - -public class PbtiScoringUtil { - - public static PbtiResponseDTO.PbtiResult calculateMbtiType(PbtiRequestDTO.PbtiQuestionRequest request) { - int eScore = 0, iScore = 0; - int sScore = 0, nScore = 0; - int tScore = 0, fScore = 0; - int jScore = 0, pScore = 0; - - // Q1~Q2 → E/I - if (request.getQOne().contains("칫솔을") || request.getQOne().contains("바로")) - eScore++; - else if (request.getQOne().contains("수건으로") || request.getQOne().contains("닦고")) - iScore++; - - if (request.getQTwo().contains("버튼") || request.getQTwo().contains("분사해")) - eScore++; - else if (request.getQTwo().contains("중간에") || request.getQTwo().contains("내리는")) - iScore++; - - // Q3~Q4 → S/N - if (request.getQThree().contains("알림처럼") || request.getQThree().contains("미리")) - sScore++; - else if (request.getQThree().contains("버스가") || request.getQThree().contains("가방에서 꺼내")) - nScore++; - - if (request.getQFour().contains("신호가 바뀌기 직전") || request.getQFour().contains("분사해")) - sScore++; - else if (request.getQFour().contains("기다리다") || request.getQFour().contains("향이 옅어지면")) - nScore++; - - // Q5~Q6 → T/F - if (request.getQFive().contains("공간") || request.getQFive().contains("퍼뜨린다")) - tScore++; - else if (request.getQFive().contains("기분") || request.getQFive().contains("헹굴 때마다")) - fScore++; - - if (request.getQSix().contains("목줄") || request.getQSix().contains("가볍게")) - tScore++; - else if (request.getQSix().contains("손목에") || request.getQSix().contains("레이어링한다")) - fScore++; - - // Q7~Q8 → J/P - if (request.getQSeven().contains("리모컨을") || request.getQSeven().contains("채널을 돌리며")) - jScore++; - else if (request.getQSeven().contains("광고가") || request.getQSeven().contains("확실히")) - pScore++; - - if (request.getQEight().contains("이불 위에서") || request.getQEight().contains("잔향을")) - jScore++; - else if (request.getQEight().contains("중앙에서") || request.getQEight().contains("톡톡")) - pScore++; - - // 키워드 결정 - String keyword1 = (eScore > iScore) ? "긍정적 임팩트를 가진 당신" - : (eScore < iScore) ? "은은한 집중형인 당신" - : "외향과 내향의 균형을 지닌 당신"; - String keyword2 = (sScore > nScore) ? "촉각에 민감한 당신" - : (sScore < nScore) ? "직관으로 이끄는 당신" - : "감각과 직관을 오가는 당신"; - String keyword3 = (tScore > fScore) ? "세부까지 놓치지 않는 당신" - : (tScore < fScore) ? "감성을 우선하는 당신" - : "사고와 감정을 조화시키는 당신"; - String keyword4 = (jScore > pScore) ? "미리 움직이는 당신" - : (jScore < pScore) ? "순간을 즐기는 당신" - : "계획과 즉흥이 공존하는 당신"; - - return new PbtiResponseDTO.PbtiResult(keyword1, keyword2, keyword3, keyword4); - } -} diff --git a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java index e1537b4..5a90593 100644 --- a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java @@ -19,8 +19,6 @@ import PerfumeOnMe.spring.repository.pbti.PbtiRepository; import PerfumeOnMe.spring.repository.user.UserRepository; import PerfumeOnMe.spring.service.external.FastApiClient; -import PerfumeOnMe.spring.service.openAi.OpenAiService; -import PerfumeOnMe.spring.service.openAi.PromptBuilder; import PerfumeOnMe.spring.util.JsonUtils; import PerfumeOnMe.spring.web.dto.Pbti.PbtiRequestDTO; import PerfumeOnMe.spring.web.dto.Pbti.PbtiResponseDTO; @@ -35,7 +33,6 @@ @Transactional public class PbtiServiceImpl implements PbtiService { - private final OpenAiService openAiService; private final ObjectMapper objectMapper; private final PbtiRepository pbtiRepository; private final UserRepository userRepository; @@ -46,22 +43,6 @@ public class PbtiServiceImpl implements PbtiService { @Override public PbtiResponseDTO.PbtiQuestionResponse searchPbti(Long userId, PbtiRequestDTO.PbtiQuestionRequest request) { - PbtiResponseDTO.PbtiResult result = PbtiScoringUtil.calculateMbtiType(request); - - String prompt = PromptBuilder.buildPromptFromRequest(request, result); - - // GPT로부터 응답 받기 - String gptResponse = openAiService.getStructuredResponse(prompt); - - // JSON → DTO 역직렬화 - PbtiResponseDTO.PbtiQuestionResponse response; - try { - response = objectMapper.readValue(gptResponse, PbtiResponseDTO.PbtiQuestionResponse.class); - } catch (JsonProcessingException e) { - log.error("GPT 응답 JSON 파싱 실패. 응답: {}", gptResponse, e); - throw new GeneralException(ErrorStatus.JSON_PARSING_ERROR); - } - // FastAPI 호출로 perfumeRecommend 대체 FastApiRecommendRequest.PbtiRequest fastApiRequest = new FastApiRecommendRequest.PbtiRequest( request.getQOne(), @@ -74,21 +55,52 @@ public PbtiResponseDTO.PbtiQuestionResponse searchPbti(Long userId, PbtiRequestD request.getQEight() ); - FastApiPbtiRecommendResponse fastApiResponse = fastApiClient.getPbtiRecommendation(fastApiRequest); - - // ⬇ FastAPI 향수 추천 결과 매핑 - List mappedPerfumes = fastApiResponse.getPerfumeRecommend() - .stream() - .map(r -> PbtiResponseDTO.PbtiQuestionResponse.PerfumeRecommend.builder() - .name(r.getName()) - .brand(r.getBrand()) - .description(r.getDescription()) - .perfumeImageUrl(r.getPerfumeImageUrl()) - .build()) - .collect(Collectors.toList()); - - // 결과 세팅 - response.setPerfumeRecommend(mappedPerfumes); + FastApiPbtiRecommendResponse fastApiResponse = fastApiClient.getFullPbtiResult(fastApiRequest); + + // ✅ 결과 매핑 + PbtiResponseDTO.PbtiQuestionResponse response = PbtiResponseDTO.PbtiQuestionResponse.builder() + .recommendation(fastApiResponse.getRecommendation()) + .summary(fastApiResponse.getSummary()) + .keywords( + fastApiResponse.getKeywords().stream() + .map(k -> PbtiResponseDTO.PbtiQuestionResponse.Keyword.builder() + .keyword(k.getKeyword()) + .keywordDescription(k.getKeywordDescription()) + .build()) + .collect(Collectors.toList()) + ) + .perfumeStyle( + PbtiResponseDTO.PbtiQuestionResponse.PerfumeStyle.builder() + .description(fastApiResponse.getPerfumeStyle().getDescription()) + .notes( + fastApiResponse.getPerfumeStyle().getNotes().stream() + .map(n -> PbtiResponseDTO.PbtiQuestionResponse.PerfumeStyle.Note.builder() + .category(n.getCategory()) + .categoryDescription(n.getCategoryDescription()) + .build()) + .collect(Collectors.toList()) + ) + .build() + ) + .scentPoint( + fastApiResponse.getScentPoint().stream() + .map(s -> PbtiResponseDTO.PbtiQuestionResponse.ScentPoint.builder() + .category(s.getCategory()) + .point(s.getPoint()) + .build()) + .collect(Collectors.toList()) + ) + .perfumeRecommend( + fastApiResponse.getPerfumeRecommend().stream() + .map(r -> PbtiResponseDTO.PbtiQuestionResponse.PerfumeRecommend.builder() + .name(r.getName()) + .brand(r.getBrand()) + .description(r.getDescription()) + .perfumeImageUrl(r.getPerfumeImageUrl()) + .build()) + .collect(Collectors.toList()) + ) + .build(); // Redis에 저장할 DTO 생성 PbtiResponseDTO.PbtiRedisDTO redisDTO = PbtiResponseDTO.PbtiRedisDTO.builder() diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiPbtiRecommendResponse.java b/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiPbtiRecommendResponse.java index a5fb83f..3f4c1f8 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiPbtiRecommendResponse.java +++ b/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiPbtiRecommendResponse.java @@ -3,12 +3,21 @@ import java.util.Collections; import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; @Getter +@Setter @NoArgsConstructor public class FastApiPbtiRecommendResponse { + private String recommendation; + private List keywords; + private PerfumeStyle perfumeStyle; + private List scentPoint; + private String summary; private List perfumeRecommend = Collections.emptyList(); // 기본값 public FastApiPbtiRecommendResponse(List perfumeRecommend) { @@ -16,6 +25,47 @@ public FastApiPbtiRecommendResponse(List perfumeRecom } @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Keyword { + private String keyword; + private String keywordDescription; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class PerfumeStyle { + private String description; + private List notes; + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class Note { + private String category; + private String categoryDescription; + } + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class ScentPoint { + private String category; + private int point; + } + + @Getter + @Setter @NoArgsConstructor public static class PbtiPerfumeRecommendation { private String name; From bdc219091d495468f1434acde3c07f5be68d1a1a Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Fri, 8 Aug 2025 10:09:05 +0900 Subject: [PATCH 317/339] =?UTF-8?q?[Fix]=20CORS=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/config/security/SecurityConfig.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java b/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java index e85d0c9..9aa9cb5 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java @@ -30,7 +30,7 @@ public class SecurityConfig { public static final String[] AUTH_WHITELIST = { "/v3/api-docs/**", "/swagger-resources/**", "/swagger-ui.html", "/swagger-ui/**", "/swagger/**", "/users/signup", "/auth/login", "/auth/social/**", "/users/reissue", - "/health", "/fragrances/allow/**", "/auth/social/**", "/favicon.ico", "/images/**", + "/health", "/fragrances/allow/**", "/favicon.ico", "/images/**", "/css/**", "/js/**", "/webjars/**" }; private final JwtAuthenticationFilter JwtAuthenticationFilter; @@ -72,14 +72,14 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); - config.setAllowedOrigins(List.of("*")); // spring: [localhost:8080, localhost:5000], localhost:3000 - config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + config.setAllowedOrigins(List.of("http://localhost:8080", "http://localhost:5000", "http://52.198.172.96:8080", + "http://localhost:5173")); // web 배포 주소 포함해야 함 + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); config.setAllowedHeaders(List.of("Authorization", "Refresh-Token", "Content-Type")); - config.setAllowCredentials(false); // origin 바꾸면 true로 설정 + config.setAllowCredentials(true); config.setExposedHeaders(List.of("Authorization", "Refresh-Token", "Content-Type")); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; } } - From 6099844ed7e7f91a0a45841b1825aa612b3ea50c Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Fri, 8 Aug 2025 10:09:32 +0900 Subject: [PATCH 318/339] =?UTF-8?q?[Fix]=20Security=20Filter=EC=97=90=20?= =?UTF-8?q?=EB=A1=9C=EA=B9=85=20=EC=B6=94=EA=B0=80=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/security/auth/filter/JwtAuthenticationFilter.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtAuthenticationFilter.java b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtAuthenticationFilter.java index 8e1b52b..2c61107 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtAuthenticationFilter.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtAuthenticationFilter.java @@ -20,6 +20,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; /* 요청에서 토큰을 추출해 유효성을 검증하고, @@ -27,6 +28,7 @@ */ @Component @RequiredArgsConstructor +@Slf4j public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; @@ -37,6 +39,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + log.debug("requestURI: {}, httpMethod: {}", request.getRequestURI(), request.getMethod()); + String accessToken = jwtTokenProvider.resolveToken(request); if (StringUtils.hasText(accessToken)) { From 5dece18d189cc05344d42cdf4d0925abf0eb3218 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Fri, 8 Aug 2025 10:09:43 +0900 Subject: [PATCH 319/339] =?UTF-8?q?[Fix]=20Http=20Method=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../security/oauth/controller/OAuthController.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/config/security/oauth/controller/OAuthController.java b/src/main/java/PerfumeOnMe/spring/config/security/oauth/controller/OAuthController.java index f1bce0d..d82e034 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/oauth/controller/OAuthController.java +++ b/src/main/java/PerfumeOnMe/spring/config/security/oauth/controller/OAuthController.java @@ -1,8 +1,8 @@ package PerfumeOnMe.spring.config.security.oauth.controller; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -25,7 +25,7 @@ public class OAuthController { private final OAuthServiceFactory serviceFactory; - @GetMapping("/{provider}") + @PostMapping("/{provider}") @Operation( summary = "소셜 로그인 API", description = "소셜 액세스 토큰을 발급하고, 해당 토큰으로 사용자 정보를 가져와 회원가입 및 로그인을 진행하는 API입니다.", @@ -52,4 +52,13 @@ public ResponseEntity> oAuthLogin( return ResponseEntity.ok().body(ApiResponse.onSuccess(result)); } + + // 인가 코드 확인용 임시 컨트롤러 + // @GetMapping("/{provider}") + public ResponseEntity> getCode( + @RequestParam("code") String code, + @PathVariable("provider") String Provider, + HttpServletResponse response) { + return ResponseEntity.ok(ApiResponse.onSuccess(code)); + } } From 11f03cefed91bf0522d0ff0f130728118b332288 Mon Sep 17 00:00:00 2001 From: leewonhee-3054 Date: Fri, 8 Aug 2025 17:17:36 +0900 Subject: [PATCH 320/339] =?UTF-8?q?[Fix]=20=ED=94=84=EB=A1=AC=ED=94=84?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring/apiPayload/code/status/ErrorStatus.java | 1 + .../java/PerfumeOnMe/spring/converter/ChatConverter.java | 2 +- .../spring/service/chatbot/ChatbotServiceImpl.java | 9 ++++++++- src/main/resources/prompts/expert.txt | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java index 2ab2471..765460f 100644 --- a/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -64,6 +64,7 @@ public enum ErrorStatus implements BaseErrorCode { FILE_NOT_FOUND(HttpStatus.BAD_REQUEST, "CHATBOT4001", "프롬프트 파일을 찾을 수 없습니다."), PROMPT_LOADING_FAIL(HttpStatus.BAD_REQUEST, "CHATBOT4002", "프롬프트 로딩에 실패하였습니다."), REQUIRED_MESSAGES(HttpStatus.BAD_REQUEST, "CHATBOT4003", "메세지를 입력하세요."), + OPENAI_RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "CHATBOT429", "OpenAI API 요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요."), // 예시,,, ARTICLE_NOT_FOUND(HttpStatus.NOT_FOUND, "ARTICLE4001", "게시글이 없습니다."); diff --git a/src/main/java/PerfumeOnMe/spring/converter/ChatConverter.java b/src/main/java/PerfumeOnMe/spring/converter/ChatConverter.java index cd03dca..5fd4ad3 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/ChatConverter.java +++ b/src/main/java/PerfumeOnMe/spring/converter/ChatConverter.java @@ -22,4 +22,4 @@ public static List toDtoList(List lis .map(ChatConverter::toDto) .collect(Collectors.toList()); } -} +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/service/chatbot/ChatbotServiceImpl.java b/src/main/java/PerfumeOnMe/spring/service/chatbot/ChatbotServiceImpl.java index 805a189..2da0ad5 100644 --- a/src/main/java/PerfumeOnMe/spring/service/chatbot/ChatbotServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/service/chatbot/ChatbotServiceImpl.java @@ -47,7 +47,7 @@ public class ChatbotServiceImpl implements ChatbotService { public void init() { this.systemPrompt = promptLoader.loadDefaultPrompt(); // 프롬프트 파일 로딩 } - + /** * userId: 현재 로그인한 사용자 ID * request: 사용자 질문이 담긴 DTO @@ -85,6 +85,13 @@ public Mono ask(Long userId, ChatBotRequestDTO.ChatBotQARequest request) .uri("/chat/completions") // OpenAI의 채팅 응답 API 엔드포인트 .bodyValue(openAiRequest)// 위에서 만든 요청 객체 전송 .retrieve() + .onStatus( // 429(Too Many Request) 에러 시 예외처리 + status -> status.value() == 429, + clientResponse -> clientResponse.bodyToMono(String.class) + .flatMap(body -> Mono.error( + new GeneralException(ErrorStatus.OPENAI_RATE_LIMIT_EXCEEDED) + )) + ) .bodyToMono(JsonNode.class) // 응답을 JSON 트리로 받음 .map(json -> json.get("choices").get(0).get("message").get("content").asText()) .map(response -> { diff --git a/src/main/resources/prompts/expert.txt b/src/main/resources/prompts/expert.txt index 04194e9..75f0d4b 100644 --- a/src/main/resources/prompts/expert.txt +++ b/src/main/resources/prompts/expert.txt @@ -37,5 +37,5 @@ 주의 사항 1. **반드시 향수는 3개 추천할 것** 2. **지정된 출력 형식만 사용하고, 그 외 설명은 절대 하지 마** -3. **말투는 친근하고 짧게** +3. **말투는 친근하고 짧게 해줘. 그대신 야 라고 부르진 말고 존댓말 써줘"** From c4f968eb009d22293d54d4cc10309433f4bbfcbd Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Fri, 8 Aug 2025 17:33:08 +0900 Subject: [PATCH 321/339] =?UTF-8?q?[Fix]=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20redirect=20URI=20=EC=88=98=EC=A0=95=20(#91?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 649be7f..4d3be4f 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -37,7 +37,7 @@ spring: kakao: client-id: ${KAKAO_REST_API_KEY} client-secret: ${KAKAO_CLIENT_SECRET} - redirect-uri: "http://localhost:8080/auth/social/kakao" + redirect-uri: "http://localhost:5173/oauth/kakao/callback" authorization-uri: https://kauth.kakao.com/oauth/authorize token-uri: https://kauth.kakao.com/oauth/token user-info-uri: https://kapi.kakao.com/v2/user/me From 99ba2f231d1b83017dacaa798e3fff9574bf67b3 Mon Sep 17 00:00:00 2001 From: bulee5328 Date: Wed, 13 Aug 2025 00:41:29 +0900 Subject: [PATCH 322/339] =?UTF-8?q?[Style]=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20=EA=B8=B0=EC=A4=80=20=ED=8C=8C=EC=9D=BC=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20(#132)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../converter/ChatConverter.java | 6 +-- .../{ => chatbot}/domain/ChatMessage.java | 3 +- .../repository}/ChatMessageRepository.java | 4 +- .../service}/ChatbotService.java | 6 +-- .../service}/ChatbotServiceImpl.java | 20 ++++---- .../service}/PromptLoader.java | 2 +- .../web/controller/ChatbotController.java | 10 ++-- .../web/dto}/ChatBotRequestDTO.java | 6 +-- .../web/dto}/ChatBotResponseDTO.java | 2 +- .../web/dto}/ChatCompletionMessage.java | 2 +- .../web/dto}/ChatCompletionRequest.java | 2 +- .../{domain => common}/base/BaseEntity.java | 11 ++-- .../{ => common}/config/AmazonConfig.java | 2 +- .../{ => common}/config/OpenAIConfig.java | 2 +- .../{ => common}/config/QueryDSLConfig.java | 2 +- .../{ => common}/config/RedisConfig.java | 3 +- .../config/RestTemplateConfig.java | 2 +- .../spring/common/config/SwaggerConfig.java | 41 +++++++++++++++ .../{ => common}/config/WebClientConfig.java | 2 +- .../properties/ImageKeywordProperties.java | 2 +- .../controller/RootController.java | 2 +- .../PerfumeOnMe/spring/common/enums/Age.java | 5 ++ .../{domain => common}/enums/Ambience.java | 2 +- .../{domain => common}/enums/Brand.java | 2 +- .../enums/FragranceGender.java | 2 +- .../enums/FragranceType.java | 2 +- .../{domain => common}/enums/Gender.java | 2 +- .../enums/KeywordCategory.java | 2 +- .../{domain => common}/enums/NoteType.java | 2 +- .../{domain => common}/enums/Personality.java | 2 +- .../{domain => common}/enums/Season.java | 2 +- .../spring/common/enums/Social.java | 5 ++ .../{domain => common}/enums/Style.java | 2 +- .../spring/common/enums/UserGender.java | 5 ++ .../fragranceInit/FragranceImportService.java | 12 ++--- .../fragranceInit/FragranceRowProcessor.java | 46 ++++++++--------- .../util/CharacterImageMapper.java | 4 +- .../util/EnumDisplayNameMapper.java | 12 ++--- .../spring/{ => common}/util/JsonUtils.java | 2 +- .../validation/annotation/ValidPage.java | 4 +- .../validation/annotation/ValidSize.java | 4 +- .../validation/validator/PageValidator.java | 4 +- .../validation/validator/SizeValidator.java | 4 +- .../spring/config/SwaggerConfig.java | 40 --------------- .../{ => diary}/converter/DiaryConverter.java | 6 +-- .../mapping => diary/domain}/Diary.java | 6 +-- .../repository}/DiaryRepository.java | 4 +- .../repository}/DiaryRepositoryCustom.java | 2 +- .../repository}/DiaryRepositoryImpl.java | 2 +- .../diary => diary/service}/DiaryService.java | 6 +-- .../service}/DiaryServiceImpl.java | 16 +++--- .../web/controller/DiaryController.java | 10 ++-- .../web/dto}/DiaryRequestDTO.java | 2 +- .../web/dto}/DiaryResponseDTO.java | 4 +- .../java/PerfumeOnMe/spring/domain/Terms.java | 51 ------------------- .../PerfumeOnMe/spring/domain/enums/Age.java | 5 -- .../spring/domain/enums/Social.java | 5 -- .../spring/domain/enums/UserGender.java | 5 -- .../spring/domain/mapping/UserTerms.java | 47 ----------------- .../fastapi}/FastApiClient.java | 8 +-- .../dto}/FastApiPbtiRecommendResponse.java | 2 +- .../fastapi/dto}/FastApiRecommendRequest.java | 2 +- .../dto}/FastApiRecommendResponse.java | 2 +- .../openai}/OpenAiApiClient.java | 6 +-- .../openai}/OpenAiService.java | 2 +- .../openai}/OpenAiServiceImpl.java | 24 ++++----- .../converter/FragranceConverter.java | 26 +++++----- .../{ => fragrance}/domain/Fragrance.java | 26 +++++----- .../{ => fragrance}/domain/Location.java | 6 +-- .../spring/{ => fragrance}/domain/Note.java | 14 ++--- .../spring/{ => fragrance}/domain/Price.java | 6 +-- .../spring/{ => fragrance}/domain/Season.java | 6 +-- .../domain/mapping/FragranceBaseNote.java | 8 +-- .../domain/mapping/FragranceLocation.java | 8 +-- .../domain/mapping/FragranceMiddleNote.java | 8 +-- .../domain/mapping/FragrancePrice.java | 8 +-- .../domain/mapping/FragranceSeason.java | 8 +-- .../domain/mapping/FragranceTopNote.java | 8 +-- .../domain/mapping/RecommendedFragrance.java | 8 +-- .../repository}/FragranceRepository.java | 4 +- .../FragranceRepositoryCustom.java | 6 +-- .../repository}/FragranceRepositoryImpl.java | 32 ++++++------ .../FragranceBaseNoteRepository.java | 4 +- .../FragranceBaseNoteRepositoryCustom.java | 4 ++ .../FragranceBaseNoteRepositoryImpl.java | 2 +- .../FragranceLocationRepository.java | 4 +- .../FragranceLocationRepositoryCustom.java | 4 ++ .../FragranceLocationRepositoryImpl.java | 2 +- .../FragranceMiddleNoteRepository.java | 4 +- .../FragranceMiddleNoteRepositoryCustom.java | 4 ++ .../FragranceMiddleNoteRepositoryImpl.java | 2 +- .../FragrancePriceRepository.java | 8 +-- .../FragrancePriceRepositoryCustom.java | 4 ++ .../FragrancePriceRepositoryImpl.java | 2 +- .../FragranceSeasonRepository.java | 4 +- .../FragranceSeasonRepositoryCustom.java | 4 ++ .../FragranceSeasonRepositoryImpl.java | 2 +- .../FragranceTopNoteRepository.java | 4 +- .../FragranceTopNoteRepositoryCustom.java | 4 ++ .../FragranceTopNoteRepositoryImpl.java | 2 +- .../location/LocationRepository.java | 4 +- .../location/LocationRepositoryCustom.java | 4 ++ .../location/LocationRepositoryImpl.java | 2 +- .../repository/note/NoteRepository.java | 4 +- .../repository/note/NoteRepositoryCustom.java | 4 ++ .../repository/note/NoteRepositoryImpl.java | 2 +- .../repository/price/PriceRepository.java | 4 +- .../price/PriceRepositoryCustom.java | 4 ++ .../repository/price/PriceRepositoryImpl.java | 2 +- .../repository/season/SeasonRepository.java | 4 +- .../season/SeasonRepositoryCustom.java | 4 ++ .../season/SeasonRepositoryImpl.java | 2 +- .../service}/FragranceService.java | 8 +-- .../service}/FragranceServiceImpl.java | 42 +++++++-------- .../validation/annotation/ValidKeyword.java | 4 +- .../validator/ValidKeywordValidator.java | 4 +- .../web/controller/FragranceController.java | 10 ++-- .../web/dto}/FragranceRequestDTO.java | 8 +-- .../web/dto}/FragranceResponseDTO.java | 2 +- .../converter/ImageKeywordConverter.java | 12 ++--- .../domain/ImageKeyword.java | 15 +++--- .../domain/ImageKeywordDescription.java | 6 +-- .../redis/ImageKeywordRedisService.java | 4 +- .../repository}/ImageKeywordRepository.java | 6 +-- .../ImageKeywordDescriptionRepository.java | 6 +-- .../ImageKeywordDescriptionService.java | 18 +++---- .../service}/ImageKeywordPreviewService.java | 18 +++---- .../service}/ImageKeywordService.java | 4 +- .../service}/ImageKeywordServiceImpl.java | 26 +++++----- .../util/ImageKeywordDescriptionUtils.java | 28 +++++----- .../util/ImageKeywordValidationUtils.java | 6 +-- .../annotation/ValidEnumKeyword.java | 4 +- .../validator/ValidEnumKeywordValidator.java | 4 +- .../controller/ImageKeywordController.java | 14 ++--- .../web/docs}/ImageKeywordControllerDocs.java | 8 +-- .../web/dto}/ImageKeywordRequestDTO.java | 16 +++--- .../web/dto}/ImageKeywordResponseDTO.java | 2 +- .../{ => pbti}/converter/PbtiConverter.java | 6 +-- .../spring/{ => pbti}/domain/PBTI.java | 7 +-- .../repository}/PbtiRepository.java | 4 +- .../pbti/repository/PbtiRepositoryCustom.java | 4 ++ .../repository}/PbtiRepositoryImpl.java | 2 +- .../pbti => pbti/service}/PbtiService.java | 6 +-- .../service}/PbtiServiceImpl.java | 24 ++++----- .../web/controller/PbtiController.java | 10 ++-- .../Pbti => pbti/web/dto}/ChatGptRequest.java | 2 +- .../web/dto}/ChatGptResponse.java | 2 +- .../Pbti => pbti/web/dto}/PbtiRequestDTO.java | 2 +- .../web/dto}/PbtiResponseDTO.java | 2 +- .../FragranceBaseNoteRepositoryCustom.java | 4 -- .../FragranceLocationRepositoryCustom.java | 4 -- .../FragranceMiddleNoteRepositoryCustom.java | 4 -- .../FragrancePriceRepositoryCustom.java | 4 -- .../FragranceSeasonRepositoryCustom.java | 4 -- .../FragranceTopNoteRepositoryCustom.java | 4 -- .../location/LocationRepositoryCustom.java | 4 -- .../repository/note/NoteRepositoryCustom.java | 4 -- .../repository/pbti/PbtiRepositoryCustom.java | 4 -- .../price/PriceRepositoryCustom.java | 4 -- .../season/SeasonRepositoryCustom.java | 4 -- .../UserFragranceRepositoryCustom.java | 5 -- .../s3 => s3file/aws}/AmazonS3Manager.java | 8 +-- .../{ => s3file}/converter/S3Converter.java | 6 +-- .../web/controller/S3Controller.java | 14 ++--- .../s3 => s3file/web/dto}/s3RequestDTO.java | 2 +- .../s3 => s3file/web/dto}/s3ResponseDTO.java | 2 +- .../security/AuthenticationManagerConfig.java | 6 +-- .../security/PasswordEncoderConfig.java | 2 +- .../{config => }/security/SecurityConfig.java | 12 ++--- .../auth/controller/LoginController.java | 10 ++-- .../auth/converter/AuthConverter.java | 6 +-- .../security/auth/dto/AuthRequestDTO.java | 2 +- .../security/auth/dto/AuthResponseDTO.java | 4 +- .../security/auth/dto/JwtProperties.java | 2 +- .../auth/filter/JwtAuthenticationFilter.java | 10 ++-- .../filter/JwtExceptionHandlerFilter.java | 2 +- .../security/auth/filter/JwtLoginFilter.java | 18 +++---- .../auth/handler/JwtAccessDeniedHandler.java | 2 +- .../handler/JwtAuthenticationEntryPoint.java | 2 +- .../manager/LogoutAccessTokenManager.java | 4 +- .../auth/manager/RefreshTokenManager.java | 4 +- .../CustomLoginAuthenticationProvider.java | 2 +- .../provider/JwtAuthenticationProvider.java | 6 +-- .../auth/provider/JwtTokenProvider.java | 6 +-- .../security/auth/service/LoginService.java | 8 +-- .../auth/service/LoginServiceImpl.java | 20 ++++---- .../auth/token/JwtAuthenticationToken.java | 4 +- .../auth/userDetails/CustomUserDetails.java | 4 +- .../userDetails/CustomUserDetailsService.java | 6 +-- .../oauth/controller/OAuthController.java | 12 ++--- .../oauth/converter/OAuthConverter.java | 6 +-- .../security/oauth/dto/KakaoProperties.java | 2 +- .../security/oauth/dto/KakaoResponseDTO.java | 2 +- .../security/oauth/service/KakaoService.java | 26 +++++----- .../security/oauth/service/OAuthService.java | 6 +-- .../oauth/service/OAuthServiceFactory.java | 4 +- .../security/oauth/util/KakaoClient.java | 6 +-- .../oauth/util/OAuthProviderResolver.java | 4 +- .../{ => user}/converter/UserConverter.java | 6 +-- .../converter/UserNoteConverter.java | 6 +-- .../spring/{ => user}/domain/User.java | 27 +++++----- .../domain/mapping/UserFragrance.java | 8 +-- .../{ => user}/domain/mapping/UserNote.java | 8 +-- .../repository}/UserRepository.java | 4 +- .../repository}/UserRepositoryCustom.java | 2 +- .../repository}/UserRepositoryImpl.java | 2 +- .../UserFragranceRepository.java | 8 +-- .../UserFragranceRepositoryCustom.java | 5 ++ .../userNote/UserNoteRepository.java | 6 +-- .../user => user/service}/UserService.java | 16 +++--- .../service}/UserServiceImpl.java | 50 +++++++++--------- .../validation/annotation/ExistUser.java | 4 +- .../validation/annotation/ExistUserAge.java | 4 +- .../annotation/ExistUserGender.java | 4 +- .../validation/annotation/ValidUserNote.java | 4 +- .../validator/ExistUserAgeValidator.java | 6 +-- .../validator/ExistUserGenderValidator.java | 6 +-- .../validator/ExistUserValidator.java | 4 +- .../validator/ValidUserNoteValidator.java | 4 +- .../web/controller/UserController.java | 16 +++--- .../user => user/web/dto}/UserRequestDTO.java | 8 +-- .../web/dto}/UserResponseDTO.java | 2 +- .../spring/{ => uuid}/domain/Uuid.java | 4 +- .../repository}/UuidRepository.java | 4 +- .../converter/WorkshopConverter.java | 14 ++--- .../{ => workshop}/domain/Workshop.java | 5 +- .../domain/WorkshopFragrance.java | 4 +- .../redis/WorkshopRedisService.java | 5 +- .../WorkshopFragranceRepository.java | 4 +- .../repository}/WorkshopRepository.java | 6 +-- .../WorkshopRecommendationService.java | 8 +-- .../service}/WorkshopResult.java | 4 +- .../service}/WorkshopResultParser.java | 2 +- .../service}/WorkshopService.java | 26 +++++----- .../validation/annotation}/ValidBaseNote.java | 4 +- .../annotation}/ValidMiddleNote.java | 4 +- .../validation/annotation}/ValidTopNote.java | 4 +- .../validator}/ValidBaseNoteValidator.java | 4 +- .../validator}/ValidMiddleNoteValidator.java | 4 +- .../validator}/ValidTopNoteValidator.java | 4 +- .../web/controller/WorkshopController.java | 12 ++--- .../web/docs}/WorkshopControllerDocs.java | 8 +-- .../web/dto}/WorkshopRequestDTO.java | 8 +-- .../web/dto}/WorkshopResponseDTO.java | 2 +- 244 files changed, 847 insertions(+), 942 deletions(-) rename src/main/java/PerfumeOnMe/spring/{ => chatbot}/converter/ChatConverter.java (79%) rename src/main/java/PerfumeOnMe/spring/{ => chatbot}/domain/ChatMessage.java (93%) rename src/main/java/PerfumeOnMe/spring/{repository/chatbot => chatbot/repository}/ChatMessageRepository.java (82%) rename src/main/java/PerfumeOnMe/spring/{service/chatbot => chatbot/service}/ChatbotService.java (65%) rename src/main/java/PerfumeOnMe/spring/{service/chatbot => chatbot/service}/ChatbotServiceImpl.java (88%) rename src/main/java/PerfumeOnMe/spring/{service/chatbot => chatbot/service}/PromptLoader.java (94%) rename src/main/java/PerfumeOnMe/spring/{ => chatbot}/web/controller/ChatbotController.java (90%) rename src/main/java/PerfumeOnMe/spring/{web/dto/chatbot => chatbot/web/dto}/ChatBotRequestDTO.java (75%) rename src/main/java/PerfumeOnMe/spring/{web/dto/chatbot => chatbot/web/dto}/ChatBotResponseDTO.java (92%) rename src/main/java/PerfumeOnMe/spring/{web/dto/chatbot => chatbot/web/dto}/ChatCompletionMessage.java (92%) rename src/main/java/PerfumeOnMe/spring/{web/dto/chatbot => chatbot/web/dto}/ChatCompletionRequest.java (88%) rename src/main/java/PerfumeOnMe/spring/{domain => common}/base/BaseEntity.java (74%) rename src/main/java/PerfumeOnMe/spring/{ => common}/config/AmazonConfig.java (97%) rename src/main/java/PerfumeOnMe/spring/{ => common}/config/OpenAIConfig.java (93%) rename src/main/java/PerfumeOnMe/spring/{ => common}/config/QueryDSLConfig.java (91%) rename src/main/java/PerfumeOnMe/spring/{ => common}/config/RedisConfig.java (87%) rename src/main/java/PerfumeOnMe/spring/{ => common}/config/RestTemplateConfig.java (96%) create mode 100644 src/main/java/PerfumeOnMe/spring/common/config/SwaggerConfig.java rename src/main/java/PerfumeOnMe/spring/{ => common}/config/WebClientConfig.java (88%) rename src/main/java/PerfumeOnMe/spring/{ => common}/config/properties/ImageKeywordProperties.java (95%) rename src/main/java/PerfumeOnMe/spring/{web => common}/controller/RootController.java (85%) create mode 100644 src/main/java/PerfumeOnMe/spring/common/enums/Age.java rename src/main/java/PerfumeOnMe/spring/{domain => common}/enums/Ambience.java (94%) rename src/main/java/PerfumeOnMe/spring/{domain => common}/enums/Brand.java (94%) rename src/main/java/PerfumeOnMe/spring/{domain => common}/enums/FragranceGender.java (85%) rename src/main/java/PerfumeOnMe/spring/{domain => common}/enums/FragranceType.java (92%) rename src/main/java/PerfumeOnMe/spring/{domain => common}/enums/Gender.java (92%) rename src/main/java/PerfumeOnMe/spring/{domain => common}/enums/KeywordCategory.java (65%) rename src/main/java/PerfumeOnMe/spring/{domain => common}/enums/NoteType.java (51%) rename src/main/java/PerfumeOnMe/spring/{domain => common}/enums/Personality.java (94%) rename src/main/java/PerfumeOnMe/spring/{domain => common}/enums/Season.java (92%) create mode 100644 src/main/java/PerfumeOnMe/spring/common/enums/Social.java rename src/main/java/PerfumeOnMe/spring/{domain => common}/enums/Style.java (94%) create mode 100644 src/main/java/PerfumeOnMe/spring/common/enums/UserGender.java rename src/main/java/PerfumeOnMe/spring/{service => common}/fragranceInit/FragranceImportService.java (92%) rename src/main/java/PerfumeOnMe/spring/{service => common}/fragranceInit/FragranceRowProcessor.java (84%) rename src/main/java/PerfumeOnMe/spring/{ => common}/util/CharacterImageMapper.java (96%) rename src/main/java/PerfumeOnMe/spring/{ => common}/util/EnumDisplayNameMapper.java (69%) rename src/main/java/PerfumeOnMe/spring/{ => common}/util/JsonUtils.java (91%) rename src/main/java/PerfumeOnMe/spring/{ => common}/validation/annotation/ValidPage.java (82%) rename src/main/java/PerfumeOnMe/spring/{ => common}/validation/annotation/ValidSize.java (82%) rename src/main/java/PerfumeOnMe/spring/{ => common}/validation/validator/PageValidator.java (72%) rename src/main/java/PerfumeOnMe/spring/{ => common}/validation/validator/SizeValidator.java (72%) delete mode 100644 src/main/java/PerfumeOnMe/spring/config/SwaggerConfig.java rename src/main/java/PerfumeOnMe/spring/{ => diary}/converter/DiaryConverter.java (64%) rename src/main/java/PerfumeOnMe/spring/{domain/mapping => diary/domain}/Diary.java (91%) rename src/main/java/PerfumeOnMe/spring/{repository/diary => diary/repository}/DiaryRepository.java (82%) rename src/main/java/PerfumeOnMe/spring/{repository/diary => diary/repository}/DiaryRepositoryCustom.java (50%) rename src/main/java/PerfumeOnMe/spring/{repository/diary => diary/repository}/DiaryRepositoryImpl.java (81%) rename src/main/java/PerfumeOnMe/spring/{service/diary => diary/service}/DiaryService.java (82%) rename src/main/java/PerfumeOnMe/spring/{service/diary => diary/service}/DiaryServiceImpl.java (88%) rename src/main/java/PerfumeOnMe/spring/{ => diary}/web/controller/DiaryController.java (95%) rename src/main/java/PerfumeOnMe/spring/{web/dto/diary => diary/web/dto}/DiaryRequestDTO.java (91%) rename src/main/java/PerfumeOnMe/spring/{web/dto/diary => diary/web/dto}/DiaryResponseDTO.java (95%) delete mode 100644 src/main/java/PerfumeOnMe/spring/domain/Terms.java delete mode 100644 src/main/java/PerfumeOnMe/spring/domain/enums/Age.java delete mode 100644 src/main/java/PerfumeOnMe/spring/domain/enums/Social.java delete mode 100644 src/main/java/PerfumeOnMe/spring/domain/enums/UserGender.java delete mode 100644 src/main/java/PerfumeOnMe/spring/domain/mapping/UserTerms.java rename src/main/java/PerfumeOnMe/spring/{service/external => external/fastapi}/FastApiClient.java (88%) rename src/main/java/PerfumeOnMe/spring/{web/dto/external => external/fastapi/dto}/FastApiPbtiRecommendResponse.java (96%) rename src/main/java/PerfumeOnMe/spring/{web/dto/external => external/fastapi/dto}/FastApiRecommendRequest.java (94%) rename src/main/java/PerfumeOnMe/spring/{web/dto/external => external/fastapi/dto}/FastApiRecommendResponse.java (91%) rename src/main/java/PerfumeOnMe/spring/{service/openAi => external/openai}/OpenAiApiClient.java (91%) rename src/main/java/PerfumeOnMe/spring/{service/openAi => external/openai}/OpenAiService.java (87%) rename src/main/java/PerfumeOnMe/spring/{service/openAi => external/openai}/OpenAiServiceImpl.java (83%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/converter/FragranceConverter.java (88%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/domain/Fragrance.java (80%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/domain/Location.java (86%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/domain/Note.java (82%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/domain/Price.java (87%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/domain/Season.java (86%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/domain/mapping/FragranceBaseNote.java (83%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/domain/mapping/FragranceLocation.java (83%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/domain/mapping/FragranceMiddleNote.java (83%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/domain/mapping/FragrancePrice.java (83%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/domain/mapping/FragranceSeason.java (83%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/domain/mapping/FragranceTopNote.java (83%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/domain/mapping/RecommendedFragrance.java (84%) rename src/main/java/PerfumeOnMe/spring/{repository/fragrance => fragrance/repository}/FragranceRepository.java (94%) rename src/main/java/PerfumeOnMe/spring/{repository/fragrance => fragrance/repository}/FragranceRepositoryCustom.java (73%) rename src/main/java/PerfumeOnMe/spring/{repository/fragrance => fragrance/repository}/FragranceRepositoryImpl.java (85%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/repository/fragranceBaseNote/FragranceBaseNoteRepository.java (58%) create mode 100644 src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceBaseNote/FragranceBaseNoteRepositoryCustom.java rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/repository/fragranceBaseNote/FragranceBaseNoteRepositoryImpl.java (76%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/repository/fragranceLocation/FragranceLocationRepository.java (59%) create mode 100644 src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceLocation/FragranceLocationRepositoryCustom.java rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/repository/fragranceLocation/FragranceLocationRepositoryImpl.java (76%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/repository/fragranceMiddleNote/FragranceMiddleNoteRepository.java (59%) create mode 100644 src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryCustom.java rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryImpl.java (76%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/repository/fragrancePrice/FragrancePriceRepository.java (52%) create mode 100644 src/main/java/PerfumeOnMe/spring/fragrance/repository/fragrancePrice/FragrancePriceRepositoryCustom.java rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/repository/fragrancePrice/FragrancePriceRepositoryImpl.java (77%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/repository/fragranceSeason/FragranceSeasonRepository.java (58%) create mode 100644 src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceSeason/FragranceSeasonRepositoryCustom.java rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/repository/fragranceSeason/FragranceSeasonRepositoryImpl.java (77%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/repository/fragranceTopNote/FragranceTopNoteRepository.java (58%) create mode 100644 src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceTopNote/FragranceTopNoteRepositoryCustom.java rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/repository/fragranceTopNote/FragranceTopNoteRepositoryImpl.java (77%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/repository/location/LocationRepository.java (68%) create mode 100644 src/main/java/PerfumeOnMe/spring/fragrance/repository/location/LocationRepositoryCustom.java rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/repository/location/LocationRepositoryImpl.java (77%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/repository/note/NoteRepository.java (68%) create mode 100644 src/main/java/PerfumeOnMe/spring/fragrance/repository/note/NoteRepositoryCustom.java rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/repository/note/NoteRepositoryImpl.java (78%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/repository/price/PriceRepository.java (71%) create mode 100644 src/main/java/PerfumeOnMe/spring/fragrance/repository/price/PriceRepositoryCustom.java rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/repository/price/PriceRepositoryImpl.java (78%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/repository/season/SeasonRepository.java (68%) create mode 100644 src/main/java/PerfumeOnMe/spring/fragrance/repository/season/SeasonRepositoryCustom.java rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/repository/season/SeasonRepositoryImpl.java (78%) rename src/main/java/PerfumeOnMe/spring/{service/fragrance => fragrance/service}/FragranceService.java (82%) rename src/main/java/PerfumeOnMe/spring/{service/fragrance => fragrance/service}/FragranceServiceImpl.java (89%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/validation/annotation/ValidKeyword.java (81%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/validation/validator/ValidKeywordValidator.java (75%) rename src/main/java/PerfumeOnMe/spring/{ => fragrance}/web/controller/FragranceController.java (97%) rename src/main/java/PerfumeOnMe/spring/{web/dto/fragrance => fragrance/web/dto}/FragranceRequestDTO.java (77%) rename src/main/java/PerfumeOnMe/spring/{web/dto/fragrance => fragrance/web/dto}/FragranceResponseDTO.java (98%) rename src/main/java/PerfumeOnMe/spring/{ => imagekeyword}/converter/ImageKeywordConverter.java (88%) rename src/main/java/PerfumeOnMe/spring/{ => imagekeyword}/domain/ImageKeyword.java (82%) rename src/main/java/PerfumeOnMe/spring/{ => imagekeyword}/domain/ImageKeywordDescription.java (87%) rename src/main/java/PerfumeOnMe/spring/{service => imagekeyword}/redis/ImageKeywordRedisService.java (93%) rename src/main/java/PerfumeOnMe/spring/{repository/imagekeyword => imagekeyword/repository}/ImageKeywordRepository.java (77%) rename src/main/java/PerfumeOnMe/spring/{ => imagekeyword}/repository/imagekeyworddescription/ImageKeywordDescriptionRepository.java (60%) rename src/main/java/PerfumeOnMe/spring/{service/imagekeyword => imagekeyword/service}/ImageKeywordDescriptionService.java (73%) rename src/main/java/PerfumeOnMe/spring/{service/imagekeyword => imagekeyword/service}/ImageKeywordPreviewService.java (82%) rename src/main/java/PerfumeOnMe/spring/{service/imagekeyword => imagekeyword/service}/ImageKeywordService.java (77%) rename src/main/java/PerfumeOnMe/spring/{service/imagekeyword => imagekeyword/service}/ImageKeywordServiceImpl.java (83%) rename src/main/java/PerfumeOnMe/spring/{ => imagekeyword}/util/ImageKeywordDescriptionUtils.java (81%) rename src/main/java/PerfumeOnMe/spring/{ => imagekeyword}/util/ImageKeywordValidationUtils.java (84%) rename src/main/java/PerfumeOnMe/spring/{ => imagekeyword}/validation/annotation/ValidEnumKeyword.java (81%) rename src/main/java/PerfumeOnMe/spring/{ => imagekeyword}/validation/validator/ValidEnumKeywordValidator.java (86%) rename src/main/java/PerfumeOnMe/spring/{ => imagekeyword}/web/controller/ImageKeywordController.java (87%) rename src/main/java/PerfumeOnMe/spring/{web/docs/imagekeyword => imagekeyword/web/docs}/ImageKeywordControllerDocs.java (97%) rename src/main/java/PerfumeOnMe/spring/{web/dto/imagekeyword => imagekeyword/web/dto}/ImageKeywordRequestDTO.java (80%) rename src/main/java/PerfumeOnMe/spring/{web/dto/imagekeyword => imagekeyword/web/dto}/ImageKeywordResponseDTO.java (98%) rename src/main/java/PerfumeOnMe/spring/{ => pbti}/converter/PbtiConverter.java (94%) rename src/main/java/PerfumeOnMe/spring/{ => pbti}/domain/PBTI.java (92%) rename src/main/java/PerfumeOnMe/spring/{repository/pbti => pbti/repository}/PbtiRepository.java (76%) create mode 100644 src/main/java/PerfumeOnMe/spring/pbti/repository/PbtiRepositoryCustom.java rename src/main/java/PerfumeOnMe/spring/{repository/pbti => pbti/repository}/PbtiRepositoryImpl.java (81%) rename src/main/java/PerfumeOnMe/spring/{service/pbti => pbti/service}/PbtiService.java (84%) rename src/main/java/PerfumeOnMe/spring/{service/pbti => pbti/service}/PbtiServiceImpl.java (91%) rename src/main/java/PerfumeOnMe/spring/{ => pbti}/web/controller/PbtiController.java (96%) rename src/main/java/PerfumeOnMe/spring/{web/dto/Pbti => pbti/web/dto}/ChatGptRequest.java (90%) rename src/main/java/PerfumeOnMe/spring/{web/dto/Pbti => pbti/web/dto}/ChatGptResponse.java (87%) rename src/main/java/PerfumeOnMe/spring/{web/dto/Pbti => pbti/web/dto}/PbtiRequestDTO.java (96%) rename src/main/java/PerfumeOnMe/spring/{web/dto/Pbti => pbti/web/dto}/PbtiResponseDTO.java (99%) delete mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragranceBaseNote/FragranceBaseNoteRepositoryCustom.java delete mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragranceLocation/FragranceLocationRepositoryCustom.java delete mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryCustom.java delete mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepositoryCustom.java delete mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragranceSeason/FragranceSeasonRepositoryCustom.java delete mode 100644 src/main/java/PerfumeOnMe/spring/repository/fragranceTopNote/FragranceTopNoteRepositoryCustom.java delete mode 100644 src/main/java/PerfumeOnMe/spring/repository/location/LocationRepositoryCustom.java delete mode 100644 src/main/java/PerfumeOnMe/spring/repository/note/NoteRepositoryCustom.java delete mode 100644 src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepositoryCustom.java delete mode 100644 src/main/java/PerfumeOnMe/spring/repository/price/PriceRepositoryCustom.java delete mode 100644 src/main/java/PerfumeOnMe/spring/repository/season/SeasonRepositoryCustom.java delete mode 100644 src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepositoryCustom.java rename src/main/java/PerfumeOnMe/spring/{aws/s3 => s3file/aws}/AmazonS3Manager.java (91%) rename src/main/java/PerfumeOnMe/spring/{ => s3file}/converter/S3Converter.java (67%) rename src/main/java/PerfumeOnMe/spring/{ => s3file}/web/controller/S3Controller.java (89%) rename src/main/java/PerfumeOnMe/spring/{web/dto/s3 => s3file/web/dto}/s3RequestDTO.java (78%) rename src/main/java/PerfumeOnMe/spring/{web/dto/s3 => s3file/web/dto}/s3ResponseDTO.java (91%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/AuthenticationManagerConfig.java (81%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/PasswordEncoderConfig.java (92%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/SecurityConfig.java (89%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/auth/controller/LoginController.java (83%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/auth/converter/AuthConverter.java (61%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/auth/dto/AuthRequestDTO.java (83%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/auth/dto/AuthResponseDTO.java (76%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/auth/dto/JwtProperties.java (90%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/auth/filter/JwtAuthenticationFilter.java (86%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/auth/filter/JwtExceptionHandlerFilter.java (96%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/auth/filter/JwtLoginFilter.java (88%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/auth/handler/JwtAccessDeniedHandler.java (96%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/auth/handler/JwtAuthenticationEntryPoint.java (96%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/auth/manager/LogoutAccessTokenManager.java (90%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/auth/manager/RefreshTokenManager.java (91%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/auth/provider/CustomLoginAuthenticationProvider.java (96%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/auth/provider/JwtAuthenticationProvider.java (88%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/auth/provider/JwtTokenProvider.java (94%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/auth/service/LoginService.java (64%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/auth/service/LoginServiceImpl.java (82%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/auth/token/JwtAuthenticationToken.java (92%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/auth/userDetails/CustomUserDetails.java (92%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/auth/userDetails/CustomUserDetailsService.java (85%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/oauth/controller/OAuthController.java (85%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/oauth/converter/OAuthConverter.java (71%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/oauth/dto/KakaoProperties.java (91%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/oauth/dto/KakaoResponseDTO.java (96%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/oauth/service/KakaoService.java (74%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/oauth/service/OAuthService.java (60%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/oauth/service/OAuthServiceFactory.java (86%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/oauth/util/KakaoClient.java (93%) rename src/main/java/PerfumeOnMe/spring/{config => }/security/oauth/util/OAuthProviderResolver.java (82%) rename src/main/java/PerfumeOnMe/spring/{ => user}/converter/UserConverter.java (87%) rename src/main/java/PerfumeOnMe/spring/{ => user}/converter/UserNoteConverter.java (56%) rename src/main/java/PerfumeOnMe/spring/{ => user}/domain/User.java (82%) rename src/main/java/PerfumeOnMe/spring/{ => user}/domain/mapping/UserFragrance.java (83%) rename src/main/java/PerfumeOnMe/spring/{ => user}/domain/mapping/UserNote.java (87%) rename src/main/java/PerfumeOnMe/spring/{repository/user => user/repository}/UserRepository.java (78%) rename src/main/java/PerfumeOnMe/spring/{repository/user => user/repository}/UserRepositoryCustom.java (50%) rename src/main/java/PerfumeOnMe/spring/{repository/user => user/repository}/UserRepositoryImpl.java (81%) rename src/main/java/PerfumeOnMe/spring/{ => user}/repository/userFragrance/UserFragranceRepository.java (73%) create mode 100644 src/main/java/PerfumeOnMe/spring/user/repository/userFragrance/UserFragranceRepositoryCustom.java rename src/main/java/PerfumeOnMe/spring/{ => user}/repository/userNote/UserNoteRepository.java (53%) rename src/main/java/PerfumeOnMe/spring/{service/user => user/service}/UserService.java (66%) rename src/main/java/PerfumeOnMe/spring/{service/user => user/service}/UserServiceImpl.java (83%) rename src/main/java/PerfumeOnMe/spring/{ => user}/validation/annotation/ExistUser.java (77%) rename src/main/java/PerfumeOnMe/spring/{ => user}/validation/annotation/ExistUserAge.java (83%) rename src/main/java/PerfumeOnMe/spring/{ => user}/validation/annotation/ExistUserGender.java (82%) rename src/main/java/PerfumeOnMe/spring/{ => user}/validation/annotation/ValidUserNote.java (82%) rename src/main/java/PerfumeOnMe/spring/{ => user}/validation/validator/ExistUserAgeValidator.java (79%) rename src/main/java/PerfumeOnMe/spring/{ => user}/validation/validator/ExistUserGenderValidator.java (77%) rename src/main/java/PerfumeOnMe/spring/{ => user}/validation/validator/ExistUserValidator.java (77%) rename src/main/java/PerfumeOnMe/spring/{ => user}/validation/validator/ValidUserNoteValidator.java (80%) rename src/main/java/PerfumeOnMe/spring/{ => user}/web/controller/UserController.java (95%) rename src/main/java/PerfumeOnMe/spring/{web/dto/user => user/web/dto}/UserRequestDTO.java (91%) rename src/main/java/PerfumeOnMe/spring/{web/dto/user => user/web/dto}/UserResponseDTO.java (92%) rename src/main/java/PerfumeOnMe/spring/{ => uuid}/domain/Uuid.java (85%) rename src/main/java/PerfumeOnMe/spring/{repository/uuid => uuid/repository}/UuidRepository.java (69%) rename src/main/java/PerfumeOnMe/spring/{ => workshop}/converter/WorkshopConverter.java (93%) rename src/main/java/PerfumeOnMe/spring/{ => workshop}/domain/Workshop.java (93%) rename src/main/java/PerfumeOnMe/spring/{ => workshop}/domain/WorkshopFragrance.java (94%) rename src/main/java/PerfumeOnMe/spring/{service => workshop}/redis/WorkshopRedisService.java (94%) rename src/main/java/PerfumeOnMe/spring/{repository/workshop => workshop/repository}/WorkshopFragranceRepository.java (94%) rename src/main/java/PerfumeOnMe/spring/{repository/workshop => workshop/repository}/WorkshopRepository.java (74%) rename src/main/java/PerfumeOnMe/spring/{service/workshop => workshop/service}/WorkshopRecommendationService.java (96%) rename src/main/java/PerfumeOnMe/spring/{service/workshop => workshop/service}/WorkshopResult.java (95%) rename src/main/java/PerfumeOnMe/spring/{service/workshop => workshop/service}/WorkshopResultParser.java (99%) rename src/main/java/PerfumeOnMe/spring/{service/workshop => workshop/service}/WorkshopService.java (90%) rename src/main/java/PerfumeOnMe/spring/{validation/annotation/workshop => workshop/validation/annotation}/ValidBaseNote.java (85%) rename src/main/java/PerfumeOnMe/spring/{validation/annotation/workshop => workshop/validation/annotation}/ValidMiddleNote.java (85%) rename src/main/java/PerfumeOnMe/spring/{validation/annotation/workshop => workshop/validation/annotation}/ValidTopNote.java (85%) rename src/main/java/PerfumeOnMe/spring/{validation/validator/workshop => workshop/validation/validator}/ValidBaseNoteValidator.java (82%) rename src/main/java/PerfumeOnMe/spring/{validation/validator/workshop => workshop/validation/validator}/ValidMiddleNoteValidator.java (83%) rename src/main/java/PerfumeOnMe/spring/{validation/validator/workshop => workshop/validation/validator}/ValidTopNoteValidator.java (84%) rename src/main/java/PerfumeOnMe/spring/{ => workshop}/web/controller/WorkshopController.java (86%) rename src/main/java/PerfumeOnMe/spring/{web/docs/workshop => workshop/web/docs}/WorkshopControllerDocs.java (96%) rename src/main/java/PerfumeOnMe/spring/{web/dto/workshop => workshop/web/dto}/WorkshopRequestDTO.java (92%) rename src/main/java/PerfumeOnMe/spring/{web/dto/workshop => workshop/web/dto}/WorkshopResponseDTO.java (99%) diff --git a/src/main/java/PerfumeOnMe/spring/converter/ChatConverter.java b/src/main/java/PerfumeOnMe/spring/chatbot/converter/ChatConverter.java similarity index 79% rename from src/main/java/PerfumeOnMe/spring/converter/ChatConverter.java rename to src/main/java/PerfumeOnMe/spring/chatbot/converter/ChatConverter.java index 5fd4ad3..d97b3b2 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/ChatConverter.java +++ b/src/main/java/PerfumeOnMe/spring/chatbot/converter/ChatConverter.java @@ -1,10 +1,10 @@ -package PerfumeOnMe.spring.converter; +package PerfumeOnMe.spring.chatbot.converter; import java.util.List; import java.util.stream.Collectors; -import PerfumeOnMe.spring.domain.ChatMessage; -import PerfumeOnMe.spring.web.dto.chatbot.ChatBotResponseDTO; +import PerfumeOnMe.spring.chatbot.domain.ChatMessage; +import PerfumeOnMe.spring.chatbot.web.dto.ChatBotResponseDTO; public class ChatConverter { // 챗봇과 사용자의 대화 단건 저장 diff --git a/src/main/java/PerfumeOnMe/spring/domain/ChatMessage.java b/src/main/java/PerfumeOnMe/spring/chatbot/domain/ChatMessage.java similarity index 93% rename from src/main/java/PerfumeOnMe/spring/domain/ChatMessage.java rename to src/main/java/PerfumeOnMe/spring/chatbot/domain/ChatMessage.java index 8e0e21e..984552f 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/ChatMessage.java +++ b/src/main/java/PerfumeOnMe/spring/chatbot/domain/ChatMessage.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.domain; +package PerfumeOnMe.spring.chatbot.domain; import java.time.LocalDateTime; @@ -6,6 +6,7 @@ import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; +import PerfumeOnMe.spring.user.domain.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; diff --git a/src/main/java/PerfumeOnMe/spring/repository/chatbot/ChatMessageRepository.java b/src/main/java/PerfumeOnMe/spring/chatbot/repository/ChatMessageRepository.java similarity index 82% rename from src/main/java/PerfumeOnMe/spring/repository/chatbot/ChatMessageRepository.java rename to src/main/java/PerfumeOnMe/spring/chatbot/repository/ChatMessageRepository.java index 0f78ece..42e8b7c 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/chatbot/ChatMessageRepository.java +++ b/src/main/java/PerfumeOnMe/spring/chatbot/repository/ChatMessageRepository.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.repository.chatbot; +package PerfumeOnMe.spring.chatbot.repository; import java.util.List; @@ -6,7 +6,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import PerfumeOnMe.spring.domain.ChatMessage; +import PerfumeOnMe.spring.chatbot.domain.ChatMessage; public interface ChatMessageRepository extends JpaRepository { // 챗봇 대화 맥락용(10개) diff --git a/src/main/java/PerfumeOnMe/spring/service/chatbot/ChatbotService.java b/src/main/java/PerfumeOnMe/spring/chatbot/service/ChatbotService.java similarity index 65% rename from src/main/java/PerfumeOnMe/spring/service/chatbot/ChatbotService.java rename to src/main/java/PerfumeOnMe/spring/chatbot/service/ChatbotService.java index 1934adc..8e027ad 100644 --- a/src/main/java/PerfumeOnMe/spring/service/chatbot/ChatbotService.java +++ b/src/main/java/PerfumeOnMe/spring/chatbot/service/ChatbotService.java @@ -1,7 +1,7 @@ -package PerfumeOnMe.spring.service.chatbot; +package PerfumeOnMe.spring.chatbot.service; -import PerfumeOnMe.spring.web.dto.chatbot.ChatBotRequestDTO; -import PerfumeOnMe.spring.web.dto.chatbot.ChatBotResponseDTO; +import PerfumeOnMe.spring.chatbot.web.dto.ChatBotRequestDTO; +import PerfumeOnMe.spring.chatbot.web.dto.ChatBotResponseDTO; import reactor.core.publisher.Mono; public interface ChatbotService { diff --git a/src/main/java/PerfumeOnMe/spring/service/chatbot/ChatbotServiceImpl.java b/src/main/java/PerfumeOnMe/spring/chatbot/service/ChatbotServiceImpl.java similarity index 88% rename from src/main/java/PerfumeOnMe/spring/service/chatbot/ChatbotServiceImpl.java rename to src/main/java/PerfumeOnMe/spring/chatbot/service/ChatbotServiceImpl.java index 2da0ad5..ffa363c 100644 --- a/src/main/java/PerfumeOnMe/spring/service/chatbot/ChatbotServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/chatbot/service/ChatbotServiceImpl.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service.chatbot; +package PerfumeOnMe.spring.chatbot.service; import java.util.ArrayList; import java.util.Collections; @@ -16,15 +16,15 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.converter.ChatConverter; -import PerfumeOnMe.spring.domain.ChatMessage; -import PerfumeOnMe.spring.domain.User; -import PerfumeOnMe.spring.repository.chatbot.ChatMessageRepository; -import PerfumeOnMe.spring.repository.user.UserRepository; -import PerfumeOnMe.spring.web.dto.chatbot.ChatBotRequestDTO; -import PerfumeOnMe.spring.web.dto.chatbot.ChatBotResponseDTO; -import PerfumeOnMe.spring.web.dto.chatbot.ChatCompletionMessage; -import PerfumeOnMe.spring.web.dto.chatbot.ChatCompletionRequest; +import PerfumeOnMe.spring.chatbot.converter.ChatConverter; +import PerfumeOnMe.spring.chatbot.domain.ChatMessage; +import PerfumeOnMe.spring.chatbot.repository.ChatMessageRepository; +import PerfumeOnMe.spring.chatbot.web.dto.ChatBotRequestDTO; +import PerfumeOnMe.spring.chatbot.web.dto.ChatBotResponseDTO; +import PerfumeOnMe.spring.chatbot.web.dto.ChatCompletionMessage; +import PerfumeOnMe.spring.chatbot.web.dto.ChatCompletionRequest; +import PerfumeOnMe.spring.user.domain.User; +import PerfumeOnMe.spring.user.repository.UserRepository; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import reactor.core.publisher.Mono; diff --git a/src/main/java/PerfumeOnMe/spring/service/chatbot/PromptLoader.java b/src/main/java/PerfumeOnMe/spring/chatbot/service/PromptLoader.java similarity index 94% rename from src/main/java/PerfumeOnMe/spring/service/chatbot/PromptLoader.java rename to src/main/java/PerfumeOnMe/spring/chatbot/service/PromptLoader.java index 770920f..da1847a 100644 --- a/src/main/java/PerfumeOnMe/spring/service/chatbot/PromptLoader.java +++ b/src/main/java/PerfumeOnMe/spring/chatbot/service/PromptLoader.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service.chatbot; +package PerfumeOnMe.spring.chatbot.service; import java.io.IOException; import java.io.InputStream; diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/ChatbotController.java b/src/main/java/PerfumeOnMe/spring/chatbot/web/controller/ChatbotController.java similarity index 90% rename from src/main/java/PerfumeOnMe/spring/web/controller/ChatbotController.java rename to src/main/java/PerfumeOnMe/spring/chatbot/web/controller/ChatbotController.java index 5b305f0..44bdbee 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/ChatbotController.java +++ b/src/main/java/PerfumeOnMe/spring/chatbot/web/controller/ChatbotController.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.controller; +package PerfumeOnMe.spring.chatbot.web.controller; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -10,10 +10,10 @@ import org.springframework.web.bind.annotation.RestController; import PerfumeOnMe.spring.apiPayload.ApiResponse; -import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; -import PerfumeOnMe.spring.service.chatbot.ChatbotService; -import PerfumeOnMe.spring.web.dto.chatbot.ChatBotRequestDTO; -import PerfumeOnMe.spring.web.dto.chatbot.ChatBotResponseDTO; +import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; +import PerfumeOnMe.spring.chatbot.service.ChatbotService; +import PerfumeOnMe.spring.chatbot.web.dto.ChatBotRequestDTO; +import PerfumeOnMe.spring.chatbot.web.dto.ChatBotResponseDTO; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameters; diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatBotRequestDTO.java b/src/main/java/PerfumeOnMe/spring/chatbot/web/dto/ChatBotRequestDTO.java similarity index 75% rename from src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatBotRequestDTO.java rename to src/main/java/PerfumeOnMe/spring/chatbot/web/dto/ChatBotRequestDTO.java index a0dfa5c..5da8e79 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatBotRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/chatbot/web/dto/ChatBotRequestDTO.java @@ -1,7 +1,7 @@ -package PerfumeOnMe.spring.web.dto.chatbot; +package PerfumeOnMe.spring.chatbot.web.dto; -import PerfumeOnMe.spring.validation.annotation.ValidPage; -import PerfumeOnMe.spring.validation.annotation.ValidSize; +import PerfumeOnMe.spring.common.validation.annotation.ValidPage; +import PerfumeOnMe.spring.common.validation.annotation.ValidSize; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatBotResponseDTO.java b/src/main/java/PerfumeOnMe/spring/chatbot/web/dto/ChatBotResponseDTO.java similarity index 92% rename from src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatBotResponseDTO.java rename to src/main/java/PerfumeOnMe/spring/chatbot/web/dto/ChatBotResponseDTO.java index b415974..c5fef91 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatBotResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/chatbot/web/dto/ChatBotResponseDTO.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.dto.chatbot; +package PerfumeOnMe.spring.chatbot.web.dto; import java.time.LocalDateTime; import java.util.List; diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatCompletionMessage.java b/src/main/java/PerfumeOnMe/spring/chatbot/web/dto/ChatCompletionMessage.java similarity index 92% rename from src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatCompletionMessage.java rename to src/main/java/PerfumeOnMe/spring/chatbot/web/dto/ChatCompletionMessage.java index 801fdc8..cd3ac3e 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatCompletionMessage.java +++ b/src/main/java/PerfumeOnMe/spring/chatbot/web/dto/ChatCompletionMessage.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.dto.chatbot; +package PerfumeOnMe.spring.chatbot.web.dto; import lombok.AllArgsConstructor; import lombok.Builder; diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatCompletionRequest.java b/src/main/java/PerfumeOnMe/spring/chatbot/web/dto/ChatCompletionRequest.java similarity index 88% rename from src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatCompletionRequest.java rename to src/main/java/PerfumeOnMe/spring/chatbot/web/dto/ChatCompletionRequest.java index 6dfd01c..2f7caa5 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/chatbot/ChatCompletionRequest.java +++ b/src/main/java/PerfumeOnMe/spring/chatbot/web/dto/ChatCompletionRequest.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.dto.chatbot; +package PerfumeOnMe.spring.chatbot.web.dto; import java.util.List; diff --git a/src/main/java/PerfumeOnMe/spring/domain/base/BaseEntity.java b/src/main/java/PerfumeOnMe/spring/common/base/BaseEntity.java similarity index 74% rename from src/main/java/PerfumeOnMe/spring/domain/base/BaseEntity.java rename to src/main/java/PerfumeOnMe/spring/common/base/BaseEntity.java index 22e75a2..993c87c 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/base/BaseEntity.java +++ b/src/main/java/PerfumeOnMe/spring/common/base/BaseEntity.java @@ -1,8 +1,9 @@ -package PerfumeOnMe.spring.domain.base; +package PerfumeOnMe.spring.common.base; import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; import lombok.Getter; + import org.springframework.data.annotation.CreatedDate; import org.springframework.data.annotation.LastModifiedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @@ -14,9 +15,9 @@ @Getter public abstract class BaseEntity { - @CreatedDate - private LocalDateTime createdAt; + @CreatedDate + private LocalDateTime createdAt; - @LastModifiedDate - private LocalDateTime updatedAt; + @LastModifiedDate + private LocalDateTime updatedAt; } diff --git a/src/main/java/PerfumeOnMe/spring/config/AmazonConfig.java b/src/main/java/PerfumeOnMe/spring/common/config/AmazonConfig.java similarity index 97% rename from src/main/java/PerfumeOnMe/spring/config/AmazonConfig.java rename to src/main/java/PerfumeOnMe/spring/common/config/AmazonConfig.java index 5421e55..48a3efb 100644 --- a/src/main/java/PerfumeOnMe/spring/config/AmazonConfig.java +++ b/src/main/java/PerfumeOnMe/spring/common/config/AmazonConfig.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config; +package PerfumeOnMe.spring.common.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; diff --git a/src/main/java/PerfumeOnMe/spring/config/OpenAIConfig.java b/src/main/java/PerfumeOnMe/spring/common/config/OpenAIConfig.java similarity index 93% rename from src/main/java/PerfumeOnMe/spring/config/OpenAIConfig.java rename to src/main/java/PerfumeOnMe/spring/common/config/OpenAIConfig.java index a1704e3..8cd92b4 100644 --- a/src/main/java/PerfumeOnMe/spring/config/OpenAIConfig.java +++ b/src/main/java/PerfumeOnMe/spring/common/config/OpenAIConfig.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config; +package PerfumeOnMe.spring.common.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; diff --git a/src/main/java/PerfumeOnMe/spring/config/QueryDSLConfig.java b/src/main/java/PerfumeOnMe/spring/common/config/QueryDSLConfig.java similarity index 91% rename from src/main/java/PerfumeOnMe/spring/config/QueryDSLConfig.java rename to src/main/java/PerfumeOnMe/spring/common/config/QueryDSLConfig.java index 099ceb2..df1c1a8 100644 --- a/src/main/java/PerfumeOnMe/spring/config/QueryDSLConfig.java +++ b/src/main/java/PerfumeOnMe/spring/common/config/QueryDSLConfig.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config; +package PerfumeOnMe.spring.common.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/PerfumeOnMe/spring/config/RedisConfig.java b/src/main/java/PerfumeOnMe/spring/common/config/RedisConfig.java similarity index 87% rename from src/main/java/PerfumeOnMe/spring/config/RedisConfig.java rename to src/main/java/PerfumeOnMe/spring/common/config/RedisConfig.java index 1eb955d..42eefa8 100644 --- a/src/main/java/PerfumeOnMe/spring/config/RedisConfig.java +++ b/src/main/java/PerfumeOnMe/spring/common/config/RedisConfig.java @@ -1,9 +1,8 @@ -package PerfumeOnMe.spring.config; +package PerfumeOnMe.spring.common.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.StringRedisTemplate; @Configuration diff --git a/src/main/java/PerfumeOnMe/spring/config/RestTemplateConfig.java b/src/main/java/PerfumeOnMe/spring/common/config/RestTemplateConfig.java similarity index 96% rename from src/main/java/PerfumeOnMe/spring/config/RestTemplateConfig.java rename to src/main/java/PerfumeOnMe/spring/common/config/RestTemplateConfig.java index c6ffdb3..cae09ec 100644 --- a/src/main/java/PerfumeOnMe/spring/config/RestTemplateConfig.java +++ b/src/main/java/PerfumeOnMe/spring/common/config/RestTemplateConfig.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config; +package PerfumeOnMe.spring.common.config; import java.nio.charset.StandardCharsets; import java.util.ArrayList; diff --git a/src/main/java/PerfumeOnMe/spring/common/config/SwaggerConfig.java b/src/main/java/PerfumeOnMe/spring/common/config/SwaggerConfig.java new file mode 100644 index 0000000..4e1aa2e --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/config/SwaggerConfig.java @@ -0,0 +1,41 @@ +package PerfumeOnMe.spring.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import io.swagger.v3.oas.models.servers.Server; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI PerFumeOnMeAPI() { + Info info = new Info() + .title("PerFume On Me") + .description("PerFume On Me API 명세서") + .version("1.0.0"); + + String jwtSchemeName = "JWT TOKEN"; + // API 요청헤더에 인증정보 포함 + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName); + // SecuritySchemes 등록 + Components components = new Components() + .addSecuritySchemes(jwtSchemeName, new SecurityScheme() + .name(jwtSchemeName) + .type(SecurityScheme.Type.HTTP) // HTTP 방식 + .scheme("bearer") + .bearerFormat("JWT")); + + return new OpenAPI() + .addServersItem(new Server().url("/")) + .info(info) + .addSecurityItem(securityRequirement) + .components(components); + } +} +// http://localhost:8080/swagger-ui/index.html#/ diff --git a/src/main/java/PerfumeOnMe/spring/config/WebClientConfig.java b/src/main/java/PerfumeOnMe/spring/common/config/WebClientConfig.java similarity index 88% rename from src/main/java/PerfumeOnMe/spring/config/WebClientConfig.java rename to src/main/java/PerfumeOnMe/spring/common/config/WebClientConfig.java index 6727d0d..3b4130a 100644 --- a/src/main/java/PerfumeOnMe/spring/config/WebClientConfig.java +++ b/src/main/java/PerfumeOnMe/spring/common/config/WebClientConfig.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config; +package PerfumeOnMe.spring.common.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/PerfumeOnMe/spring/config/properties/ImageKeywordProperties.java b/src/main/java/PerfumeOnMe/spring/common/config/properties/ImageKeywordProperties.java similarity index 95% rename from src/main/java/PerfumeOnMe/spring/config/properties/ImageKeywordProperties.java rename to src/main/java/PerfumeOnMe/spring/common/config/properties/ImageKeywordProperties.java index ba5d7c1..8464265 100644 --- a/src/main/java/PerfumeOnMe/spring/config/properties/ImageKeywordProperties.java +++ b/src/main/java/PerfumeOnMe/spring/common/config/properties/ImageKeywordProperties.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config.properties; +package PerfumeOnMe.spring.common.config.properties; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/RootController.java b/src/main/java/PerfumeOnMe/spring/common/controller/RootController.java similarity index 85% rename from src/main/java/PerfumeOnMe/spring/web/controller/RootController.java rename to src/main/java/PerfumeOnMe/spring/common/controller/RootController.java index 76ae080..cb9e2ae 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/RootController.java +++ b/src/main/java/PerfumeOnMe/spring/common/controller/RootController.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.controller; +package PerfumeOnMe.spring.common.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; diff --git a/src/main/java/PerfumeOnMe/spring/common/enums/Age.java b/src/main/java/PerfumeOnMe/spring/common/enums/Age.java new file mode 100644 index 0000000..8bdc16a --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/enums/Age.java @@ -0,0 +1,5 @@ +package PerfumeOnMe.spring.common.enums; + +public enum Age { + TEENAGER, TWENTIES, THIRTIES, FORTIES, NONE +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Ambience.java b/src/main/java/PerfumeOnMe/spring/common/enums/Ambience.java similarity index 94% rename from src/main/java/PerfumeOnMe/spring/domain/enums/Ambience.java rename to src/main/java/PerfumeOnMe/spring/common/enums/Ambience.java index e108677..66eb5b8 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/Ambience.java +++ b/src/main/java/PerfumeOnMe/spring/common/enums/Ambience.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.domain.enums; +package PerfumeOnMe.spring.common.enums; import lombok.Getter; diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Brand.java b/src/main/java/PerfumeOnMe/spring/common/enums/Brand.java similarity index 94% rename from src/main/java/PerfumeOnMe/spring/domain/enums/Brand.java rename to src/main/java/PerfumeOnMe/spring/common/enums/Brand.java index e5aa9a8..f4ece08 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/Brand.java +++ b/src/main/java/PerfumeOnMe/spring/common/enums/Brand.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.domain.enums; +package PerfumeOnMe.spring.common.enums; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/FragranceGender.java b/src/main/java/PerfumeOnMe/spring/common/enums/FragranceGender.java similarity index 85% rename from src/main/java/PerfumeOnMe/spring/domain/enums/FragranceGender.java rename to src/main/java/PerfumeOnMe/spring/common/enums/FragranceGender.java index 3fb271c..1f59cf5 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/FragranceGender.java +++ b/src/main/java/PerfumeOnMe/spring/common/enums/FragranceGender.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.domain.enums; +package PerfumeOnMe.spring.common.enums; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/FragranceType.java b/src/main/java/PerfumeOnMe/spring/common/enums/FragranceType.java similarity index 92% rename from src/main/java/PerfumeOnMe/spring/domain/enums/FragranceType.java rename to src/main/java/PerfumeOnMe/spring/common/enums/FragranceType.java index 6d375da..e776c32 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/FragranceType.java +++ b/src/main/java/PerfumeOnMe/spring/common/enums/FragranceType.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.domain.enums; +package PerfumeOnMe.spring.common.enums; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Gender.java b/src/main/java/PerfumeOnMe/spring/common/enums/Gender.java similarity index 92% rename from src/main/java/PerfumeOnMe/spring/domain/enums/Gender.java rename to src/main/java/PerfumeOnMe/spring/common/enums/Gender.java index b942147..554133a 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/Gender.java +++ b/src/main/java/PerfumeOnMe/spring/common/enums/Gender.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.domain.enums; +package PerfumeOnMe.spring.common.enums; import lombok.Getter; diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/KeywordCategory.java b/src/main/java/PerfumeOnMe/spring/common/enums/KeywordCategory.java similarity index 65% rename from src/main/java/PerfumeOnMe/spring/domain/enums/KeywordCategory.java rename to src/main/java/PerfumeOnMe/spring/common/enums/KeywordCategory.java index f76088a..f8a73f0 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/KeywordCategory.java +++ b/src/main/java/PerfumeOnMe/spring/common/enums/KeywordCategory.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.domain.enums; +package PerfumeOnMe.spring.common.enums; public enum KeywordCategory { AMBIENCE, STYLE, GENDER, SEASON, PERSONALITY diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/NoteType.java b/src/main/java/PerfumeOnMe/spring/common/enums/NoteType.java similarity index 51% rename from src/main/java/PerfumeOnMe/spring/domain/enums/NoteType.java rename to src/main/java/PerfumeOnMe/spring/common/enums/NoteType.java index 21bcc6f..8478b5b 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/NoteType.java +++ b/src/main/java/PerfumeOnMe/spring/common/enums/NoteType.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.domain.enums; +package PerfumeOnMe.spring.common.enums; public enum NoteType { TOP, MIDDLE, BASE diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Personality.java b/src/main/java/PerfumeOnMe/spring/common/enums/Personality.java similarity index 94% rename from src/main/java/PerfumeOnMe/spring/domain/enums/Personality.java rename to src/main/java/PerfumeOnMe/spring/common/enums/Personality.java index cd91384..c2410ab 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/Personality.java +++ b/src/main/java/PerfumeOnMe/spring/common/enums/Personality.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.domain.enums; +package PerfumeOnMe.spring.common.enums; import lombok.Getter; diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Season.java b/src/main/java/PerfumeOnMe/spring/common/enums/Season.java similarity index 92% rename from src/main/java/PerfumeOnMe/spring/domain/enums/Season.java rename to src/main/java/PerfumeOnMe/spring/common/enums/Season.java index 313bd35..655b07d 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/Season.java +++ b/src/main/java/PerfumeOnMe/spring/common/enums/Season.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.domain.enums; +package PerfumeOnMe.spring.common.enums; import lombok.Getter; diff --git a/src/main/java/PerfumeOnMe/spring/common/enums/Social.java b/src/main/java/PerfumeOnMe/spring/common/enums/Social.java new file mode 100644 index 0000000..365aea1 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/enums/Social.java @@ -0,0 +1,5 @@ +package PerfumeOnMe.spring.common.enums; + +public enum Social { + LOCAL, KAKAO +} diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Style.java b/src/main/java/PerfumeOnMe/spring/common/enums/Style.java similarity index 94% rename from src/main/java/PerfumeOnMe/spring/domain/enums/Style.java rename to src/main/java/PerfumeOnMe/spring/common/enums/Style.java index 001babe..943a3eb 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/Style.java +++ b/src/main/java/PerfumeOnMe/spring/common/enums/Style.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.domain.enums; +package PerfumeOnMe.spring.common.enums; import lombok.Getter; diff --git a/src/main/java/PerfumeOnMe/spring/common/enums/UserGender.java b/src/main/java/PerfumeOnMe/spring/common/enums/UserGender.java new file mode 100644 index 0000000..f8bdfd7 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/enums/UserGender.java @@ -0,0 +1,5 @@ +package PerfumeOnMe.spring.common.enums; + +public enum UserGender { + MALE, FEMALE, NONE +} diff --git a/src/main/java/PerfumeOnMe/spring/service/fragranceInit/FragranceImportService.java b/src/main/java/PerfumeOnMe/spring/common/fragranceInit/FragranceImportService.java similarity index 92% rename from src/main/java/PerfumeOnMe/spring/service/fragranceInit/FragranceImportService.java rename to src/main/java/PerfumeOnMe/spring/common/fragranceInit/FragranceImportService.java index 0a2a91a..c37d175 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragranceInit/FragranceImportService.java +++ b/src/main/java/PerfumeOnMe/spring/common/fragranceInit/FragranceImportService.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service.fragranceInit; +package PerfumeOnMe.spring.common.fragranceInit; import java.io.InputStream; @@ -13,11 +13,11 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.domain.Price; -import PerfumeOnMe.spring.domain.mapping.FragrancePrice; -import PerfumeOnMe.spring.repository.fragrance.FragranceRepository; -import PerfumeOnMe.spring.repository.fragrancePrice.FragrancePriceRepository; -import PerfumeOnMe.spring.repository.price.PriceRepository; +import PerfumeOnMe.spring.fragrance.domain.Price; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragrancePrice; +import PerfumeOnMe.spring.fragrance.repository.FragranceRepository; +import PerfumeOnMe.spring.fragrance.repository.fragrancePrice.FragrancePriceRepository; +import PerfumeOnMe.spring.fragrance.repository.price.PriceRepository; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/PerfumeOnMe/spring/service/fragranceInit/FragranceRowProcessor.java b/src/main/java/PerfumeOnMe/spring/common/fragranceInit/FragranceRowProcessor.java similarity index 84% rename from src/main/java/PerfumeOnMe/spring/service/fragranceInit/FragranceRowProcessor.java rename to src/main/java/PerfumeOnMe/spring/common/fragranceInit/FragranceRowProcessor.java index b610e4e..5a699cb 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragranceInit/FragranceRowProcessor.java +++ b/src/main/java/PerfumeOnMe/spring/common/fragranceInit/FragranceRowProcessor.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service.fragranceInit; +package PerfumeOnMe.spring.common.fragranceInit; import java.util.Arrays; @@ -9,28 +9,28 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.domain.Fragrance; -import PerfumeOnMe.spring.domain.Location; -import PerfumeOnMe.spring.domain.Note; -import PerfumeOnMe.spring.domain.Season; -import PerfumeOnMe.spring.domain.enums.Brand; -import PerfumeOnMe.spring.domain.enums.FragranceGender; -import PerfumeOnMe.spring.domain.enums.FragranceType; -import PerfumeOnMe.spring.domain.enums.NoteType; -import PerfumeOnMe.spring.domain.mapping.FragranceBaseNote; -import PerfumeOnMe.spring.domain.mapping.FragranceLocation; -import PerfumeOnMe.spring.domain.mapping.FragranceMiddleNote; -import PerfumeOnMe.spring.domain.mapping.FragranceSeason; -import PerfumeOnMe.spring.domain.mapping.FragranceTopNote; -import PerfumeOnMe.spring.repository.fragrance.FragranceRepository; -import PerfumeOnMe.spring.repository.fragranceBaseNote.FragranceBaseNoteRepository; -import PerfumeOnMe.spring.repository.fragranceLocation.FragranceLocationRepository; -import PerfumeOnMe.spring.repository.fragranceMiddleNote.FragranceMiddleNoteRepository; -import PerfumeOnMe.spring.repository.fragranceSeason.FragranceSeasonRepository; -import PerfumeOnMe.spring.repository.fragranceTopNote.FragranceTopNoteRepository; -import PerfumeOnMe.spring.repository.location.LocationRepository; -import PerfumeOnMe.spring.repository.note.NoteRepository; -import PerfumeOnMe.spring.repository.season.SeasonRepository; +import PerfumeOnMe.spring.common.enums.Brand; +import PerfumeOnMe.spring.common.enums.FragranceGender; +import PerfumeOnMe.spring.common.enums.FragranceType; +import PerfumeOnMe.spring.common.enums.NoteType; +import PerfumeOnMe.spring.fragrance.domain.Fragrance; +import PerfumeOnMe.spring.fragrance.domain.Location; +import PerfumeOnMe.spring.fragrance.domain.Note; +import PerfumeOnMe.spring.fragrance.domain.Season; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceBaseNote; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceLocation; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceMiddleNote; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceSeason; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceTopNote; +import PerfumeOnMe.spring.fragrance.repository.FragranceRepository; +import PerfumeOnMe.spring.fragrance.repository.fragranceBaseNote.FragranceBaseNoteRepository; +import PerfumeOnMe.spring.fragrance.repository.fragranceLocation.FragranceLocationRepository; +import PerfumeOnMe.spring.fragrance.repository.fragranceMiddleNote.FragranceMiddleNoteRepository; +import PerfumeOnMe.spring.fragrance.repository.fragranceSeason.FragranceSeasonRepository; +import PerfumeOnMe.spring.fragrance.repository.fragranceTopNote.FragranceTopNoteRepository; +import PerfumeOnMe.spring.fragrance.repository.location.LocationRepository; +import PerfumeOnMe.spring.fragrance.repository.note.NoteRepository; +import PerfumeOnMe.spring.fragrance.repository.season.SeasonRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/PerfumeOnMe/spring/util/CharacterImageMapper.java b/src/main/java/PerfumeOnMe/spring/common/util/CharacterImageMapper.java similarity index 96% rename from src/main/java/PerfumeOnMe/spring/util/CharacterImageMapper.java rename to src/main/java/PerfumeOnMe/spring/common/util/CharacterImageMapper.java index e5b101e..14a6738 100644 --- a/src/main/java/PerfumeOnMe/spring/util/CharacterImageMapper.java +++ b/src/main/java/PerfumeOnMe/spring/common/util/CharacterImageMapper.java @@ -1,8 +1,8 @@ -package PerfumeOnMe.spring.util; +package PerfumeOnMe.spring.common.util; import java.util.Map; -import PerfumeOnMe.spring.domain.enums.Ambience; +import PerfumeOnMe.spring.common.enums.Ambience; import lombok.AccessLevel; import lombok.NoArgsConstructor; diff --git a/src/main/java/PerfumeOnMe/spring/util/EnumDisplayNameMapper.java b/src/main/java/PerfumeOnMe/spring/common/util/EnumDisplayNameMapper.java similarity index 69% rename from src/main/java/PerfumeOnMe/spring/util/EnumDisplayNameMapper.java rename to src/main/java/PerfumeOnMe/spring/common/util/EnumDisplayNameMapper.java index febb6e3..8a76ff1 100644 --- a/src/main/java/PerfumeOnMe/spring/util/EnumDisplayNameMapper.java +++ b/src/main/java/PerfumeOnMe/spring/common/util/EnumDisplayNameMapper.java @@ -1,12 +1,12 @@ -package PerfumeOnMe.spring.util; +package PerfumeOnMe.spring.common.util; import java.util.List; -import PerfumeOnMe.spring.domain.enums.Ambience; -import PerfumeOnMe.spring.domain.enums.Gender; -import PerfumeOnMe.spring.domain.enums.Personality; -import PerfumeOnMe.spring.domain.enums.Season; -import PerfumeOnMe.spring.domain.enums.Style; +import PerfumeOnMe.spring.common.enums.Ambience; +import PerfumeOnMe.spring.common.enums.Gender; +import PerfumeOnMe.spring.common.enums.Personality; +import PerfumeOnMe.spring.common.enums.Season; +import PerfumeOnMe.spring.common.enums.Style; public class EnumDisplayNameMapper { diff --git a/src/main/java/PerfumeOnMe/spring/util/JsonUtils.java b/src/main/java/PerfumeOnMe/spring/common/util/JsonUtils.java similarity index 91% rename from src/main/java/PerfumeOnMe/spring/util/JsonUtils.java rename to src/main/java/PerfumeOnMe/spring/common/util/JsonUtils.java index f459b3e..d85a418 100644 --- a/src/main/java/PerfumeOnMe/spring/util/JsonUtils.java +++ b/src/main/java/PerfumeOnMe/spring/common/util/JsonUtils.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.util; +package PerfumeOnMe.spring.common.util; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidPage.java b/src/main/java/PerfumeOnMe/spring/common/validation/annotation/ValidPage.java similarity index 82% rename from src/main/java/PerfumeOnMe/spring/validation/annotation/ValidPage.java rename to src/main/java/PerfumeOnMe/spring/common/validation/annotation/ValidPage.java index 9ea309c..94c511e 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidPage.java +++ b/src/main/java/PerfumeOnMe/spring/common/validation/annotation/ValidPage.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.validation.annotation; +package PerfumeOnMe.spring.common.validation.annotation; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; @@ -6,7 +6,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import PerfumeOnMe.spring.validation.validator.PageValidator; +import PerfumeOnMe.spring.common.validation.validator.PageValidator; import jakarta.validation.Constraint; import jakarta.validation.Payload; diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidSize.java b/src/main/java/PerfumeOnMe/spring/common/validation/annotation/ValidSize.java similarity index 82% rename from src/main/java/PerfumeOnMe/spring/validation/annotation/ValidSize.java rename to src/main/java/PerfumeOnMe/spring/common/validation/annotation/ValidSize.java index 727a07f..5ac7e53 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidSize.java +++ b/src/main/java/PerfumeOnMe/spring/common/validation/annotation/ValidSize.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.validation.annotation; +package PerfumeOnMe.spring.common.validation.annotation; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; @@ -6,7 +6,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import PerfumeOnMe.spring.validation.validator.SizeValidator; +import PerfumeOnMe.spring.common.validation.validator.SizeValidator; import jakarta.validation.Constraint; import jakarta.validation.Payload; diff --git a/src/main/java/PerfumeOnMe/spring/validation/validator/PageValidator.java b/src/main/java/PerfumeOnMe/spring/common/validation/validator/PageValidator.java similarity index 72% rename from src/main/java/PerfumeOnMe/spring/validation/validator/PageValidator.java rename to src/main/java/PerfumeOnMe/spring/common/validation/validator/PageValidator.java index 782ce5c..9ad9223 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/validator/PageValidator.java +++ b/src/main/java/PerfumeOnMe/spring/common/validation/validator/PageValidator.java @@ -1,6 +1,6 @@ -package PerfumeOnMe.spring.validation.validator; +package PerfumeOnMe.spring.common.validation.validator; -import PerfumeOnMe.spring.validation.annotation.ValidPage; +import PerfumeOnMe.spring.common.validation.annotation.ValidPage; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; diff --git a/src/main/java/PerfumeOnMe/spring/validation/validator/SizeValidator.java b/src/main/java/PerfumeOnMe/spring/common/validation/validator/SizeValidator.java similarity index 72% rename from src/main/java/PerfumeOnMe/spring/validation/validator/SizeValidator.java rename to src/main/java/PerfumeOnMe/spring/common/validation/validator/SizeValidator.java index a84ee19..37bb690 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/validator/SizeValidator.java +++ b/src/main/java/PerfumeOnMe/spring/common/validation/validator/SizeValidator.java @@ -1,6 +1,6 @@ -package PerfumeOnMe.spring.validation.validator; +package PerfumeOnMe.spring.common.validation.validator; -import PerfumeOnMe.spring.validation.annotation.ValidSize; +import PerfumeOnMe.spring.common.validation.annotation.ValidSize; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; diff --git a/src/main/java/PerfumeOnMe/spring/config/SwaggerConfig.java b/src/main/java/PerfumeOnMe/spring/config/SwaggerConfig.java deleted file mode 100644 index 80cca33..0000000 --- a/src/main/java/PerfumeOnMe/spring/config/SwaggerConfig.java +++ /dev/null @@ -1,40 +0,0 @@ -package PerfumeOnMe.spring.config; - -import io.swagger.v3.oas.models.Components; -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.info.Info; -import io.swagger.v3.oas.models.security.SecurityRequirement; -import io.swagger.v3.oas.models.security.SecurityScheme; -import io.swagger.v3.oas.models.servers.Server; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Configuration -public class SwaggerConfig { - - @Bean - public OpenAPI PerFumeOnMeAPI() { - Info info = new Info() - .title("PerFume On Me") - .description("PerFume On Me API 명세서") - .version("1.0.0"); - - String jwtSchemeName = "JWT TOKEN"; - // API 요청헤더에 인증정보 포함 - SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName); - // SecuritySchemes 등록 - Components components = new Components() - .addSecuritySchemes(jwtSchemeName, new SecurityScheme() - .name(jwtSchemeName) - .type(SecurityScheme.Type.HTTP) // HTTP 방식 - .scheme("bearer") - .bearerFormat("JWT")); - - return new OpenAPI() - .addServersItem(new Server().url("/")) - .info(info) - .addSecurityItem(securityRequirement) - .components(components); - } -} -// http://localhost:8080/swagger-ui/index.html#/ diff --git a/src/main/java/PerfumeOnMe/spring/converter/DiaryConverter.java b/src/main/java/PerfumeOnMe/spring/diary/converter/DiaryConverter.java similarity index 64% rename from src/main/java/PerfumeOnMe/spring/converter/DiaryConverter.java rename to src/main/java/PerfumeOnMe/spring/diary/converter/DiaryConverter.java index 0c4fb50..a3c6823 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/DiaryConverter.java +++ b/src/main/java/PerfumeOnMe/spring/diary/converter/DiaryConverter.java @@ -1,7 +1,7 @@ -package PerfumeOnMe.spring.converter; +package PerfumeOnMe.spring.diary.converter; -import PerfumeOnMe.spring.domain.mapping.Diary; -import PerfumeOnMe.spring.web.dto.diary.DiaryResponseDTO; +import PerfumeOnMe.spring.diary.web.dto.DiaryResponseDTO; +import PerfumeOnMe.spring.diary.domain.Diary; public class DiaryConverter { diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/Diary.java b/src/main/java/PerfumeOnMe/spring/diary/domain/Diary.java similarity index 91% rename from src/main/java/PerfumeOnMe/spring/domain/mapping/Diary.java rename to src/main/java/PerfumeOnMe/spring/diary/domain/Diary.java index 78f634c..d9e451f 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/Diary.java +++ b/src/main/java/PerfumeOnMe/spring/diary/domain/Diary.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.domain.mapping; +package PerfumeOnMe.spring.diary.domain; import java.time.LocalDate; @@ -7,8 +7,8 @@ import com.fasterxml.jackson.annotation.JsonFormat; -import PerfumeOnMe.spring.domain.User; -import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.common.base.BaseEntity; +import PerfumeOnMe.spring.user.domain.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; diff --git a/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepository.java b/src/main/java/PerfumeOnMe/spring/diary/repository/DiaryRepository.java similarity index 82% rename from src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepository.java rename to src/main/java/PerfumeOnMe/spring/diary/repository/DiaryRepository.java index 9756473..4d2efed 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepository.java +++ b/src/main/java/PerfumeOnMe/spring/diary/repository/DiaryRepository.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.repository.diary; +package PerfumeOnMe.spring.diary.repository; import java.time.LocalDate; import java.util.List; @@ -6,7 +6,7 @@ import org.springframework.data.jpa.repository.JpaRepository; -import PerfumeOnMe.spring.domain.mapping.Diary; +import PerfumeOnMe.spring.diary.domain.Diary; public interface DiaryRepository extends JpaRepository, DiaryRepositoryCustom { diff --git a/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/diary/repository/DiaryRepositoryCustom.java similarity index 50% rename from src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepositoryCustom.java rename to src/main/java/PerfumeOnMe/spring/diary/repository/DiaryRepositoryCustom.java index 7bdfca2..94e2afe 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepositoryCustom.java +++ b/src/main/java/PerfumeOnMe/spring/diary/repository/DiaryRepositoryCustom.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.repository.diary; +package PerfumeOnMe.spring.diary.repository; public interface DiaryRepositoryCustom { diff --git a/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/diary/repository/DiaryRepositoryImpl.java similarity index 81% rename from src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepositoryImpl.java rename to src/main/java/PerfumeOnMe/spring/diary/repository/DiaryRepositoryImpl.java index be36e8b..73518cd 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/diary/DiaryRepositoryImpl.java +++ b/src/main/java/PerfumeOnMe/spring/diary/repository/DiaryRepositoryImpl.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.repository.diary; +package PerfumeOnMe.spring.diary.repository; import org.springframework.stereotype.Repository; diff --git a/src/main/java/PerfumeOnMe/spring/service/diary/DiaryService.java b/src/main/java/PerfumeOnMe/spring/diary/service/DiaryService.java similarity index 82% rename from src/main/java/PerfumeOnMe/spring/service/diary/DiaryService.java rename to src/main/java/PerfumeOnMe/spring/diary/service/DiaryService.java index 2350821..50d4936 100644 --- a/src/main/java/PerfumeOnMe/spring/service/diary/DiaryService.java +++ b/src/main/java/PerfumeOnMe/spring/diary/service/DiaryService.java @@ -1,10 +1,10 @@ -package PerfumeOnMe.spring.service.diary; +package PerfumeOnMe.spring.diary.service; import java.time.LocalDate; import java.util.List; -import PerfumeOnMe.spring.web.dto.diary.DiaryRequestDTO; -import PerfumeOnMe.spring.web.dto.diary.DiaryResponseDTO; +import PerfumeOnMe.spring.diary.web.dto.DiaryRequestDTO; +import PerfumeOnMe.spring.diary.web.dto.DiaryResponseDTO; public interface DiaryService { diff --git a/src/main/java/PerfumeOnMe/spring/service/diary/DiaryServiceImpl.java b/src/main/java/PerfumeOnMe/spring/diary/service/DiaryServiceImpl.java similarity index 88% rename from src/main/java/PerfumeOnMe/spring/service/diary/DiaryServiceImpl.java rename to src/main/java/PerfumeOnMe/spring/diary/service/DiaryServiceImpl.java index 5e9cf14..85f8fda 100644 --- a/src/main/java/PerfumeOnMe/spring/service/diary/DiaryServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/diary/service/DiaryServiceImpl.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service.diary; +package PerfumeOnMe.spring.diary.service; import java.time.LocalDate; import java.util.List; @@ -8,13 +8,13 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.converter.DiaryConverter; -import PerfumeOnMe.spring.domain.User; -import PerfumeOnMe.spring.domain.mapping.Diary; -import PerfumeOnMe.spring.repository.diary.DiaryRepository; -import PerfumeOnMe.spring.repository.user.UserRepository; -import PerfumeOnMe.spring.web.dto.diary.DiaryRequestDTO; -import PerfumeOnMe.spring.web.dto.diary.DiaryResponseDTO; +import PerfumeOnMe.spring.diary.converter.DiaryConverter; +import PerfumeOnMe.spring.diary.repository.DiaryRepository; +import PerfumeOnMe.spring.diary.web.dto.DiaryRequestDTO; +import PerfumeOnMe.spring.diary.web.dto.DiaryResponseDTO; +import PerfumeOnMe.spring.diary.domain.Diary; +import PerfumeOnMe.spring.user.domain.User; +import PerfumeOnMe.spring.user.repository.UserRepository; import lombok.RequiredArgsConstructor; @Service diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java b/src/main/java/PerfumeOnMe/spring/diary/web/controller/DiaryController.java similarity index 95% rename from src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java rename to src/main/java/PerfumeOnMe/spring/diary/web/controller/DiaryController.java index 16bd6db..b928935 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/DiaryController.java +++ b/src/main/java/PerfumeOnMe/spring/diary/web/controller/DiaryController.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.controller; +package PerfumeOnMe.spring.diary.web.controller; import java.time.LocalDate; import java.util.List; @@ -16,10 +16,10 @@ import PerfumeOnMe.spring.apiPayload.ApiResponse; import PerfumeOnMe.spring.apiPayload.code.status.SuccessStatus; -import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; -import PerfumeOnMe.spring.service.diary.DiaryService; -import PerfumeOnMe.spring.web.dto.diary.DiaryRequestDTO; -import PerfumeOnMe.spring.web.dto.diary.DiaryResponseDTO; +import PerfumeOnMe.spring.diary.service.DiaryService; +import PerfumeOnMe.spring.diary.web.dto.DiaryRequestDTO; +import PerfumeOnMe.spring.diary.web.dto.DiaryResponseDTO; +import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryRequestDTO.java b/src/main/java/PerfumeOnMe/spring/diary/web/dto/DiaryRequestDTO.java similarity index 91% rename from src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryRequestDTO.java rename to src/main/java/PerfumeOnMe/spring/diary/web/dto/DiaryRequestDTO.java index 438a78d..4bd4b81 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/diary/web/dto/DiaryRequestDTO.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.dto.diary; +package PerfumeOnMe.spring.diary.web.dto; import java.time.LocalDate; diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java b/src/main/java/PerfumeOnMe/spring/diary/web/dto/DiaryResponseDTO.java similarity index 95% rename from src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java rename to src/main/java/PerfumeOnMe/spring/diary/web/dto/DiaryResponseDTO.java index e184043..c241a1d 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/diary/DiaryResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/diary/web/dto/DiaryResponseDTO.java @@ -1,10 +1,10 @@ -package PerfumeOnMe.spring.web.dto.diary; +package PerfumeOnMe.spring.diary.web.dto; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; -import PerfumeOnMe.spring.domain.mapping.Diary; +import PerfumeOnMe.spring.diary.domain.Diary; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; diff --git a/src/main/java/PerfumeOnMe/spring/domain/Terms.java b/src/main/java/PerfumeOnMe/spring/domain/Terms.java deleted file mode 100644 index 046ede6..0000000 --- a/src/main/java/PerfumeOnMe/spring/domain/Terms.java +++ /dev/null @@ -1,51 +0,0 @@ -package PerfumeOnMe.spring.domain; - -import java.util.ArrayList; -import java.util.List; - -import org.hibernate.annotations.DynamicInsert; -import org.hibernate.annotations.DynamicUpdate; - -import PerfumeOnMe.spring.domain.base.BaseEntity; -import PerfumeOnMe.spring.domain.mapping.UserTerms; -import jakarta.persistence.CascadeType; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.OneToMany; -import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Getter -@Builder -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@DynamicInsert -@DynamicUpdate -@Table(name = "terms") -public class Terms extends BaseEntity { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(nullable = false, unique = true, length = 100) - private String title; - - @Column(columnDefinition = "TEXT", nullable = false) - private String content; - - @Column(nullable = false) - private boolean required; - - @OneToMany(mappedBy = "terms", cascade = CascadeType.ALL) - @Builder.Default - private List userTermsList = new ArrayList<>(); -} diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Age.java b/src/main/java/PerfumeOnMe/spring/domain/enums/Age.java deleted file mode 100644 index cc43d7a..0000000 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/Age.java +++ /dev/null @@ -1,5 +0,0 @@ -package PerfumeOnMe.spring.domain.enums; - -public enum Age { - TEENAGER, TWENTIES, THIRTIES, FORTIES, NONE -} diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/Social.java b/src/main/java/PerfumeOnMe/spring/domain/enums/Social.java deleted file mode 100644 index 281364b..0000000 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/Social.java +++ /dev/null @@ -1,5 +0,0 @@ -package PerfumeOnMe.spring.domain.enums; - -public enum Social { - LOCAL, KAKAO -} diff --git a/src/main/java/PerfumeOnMe/spring/domain/enums/UserGender.java b/src/main/java/PerfumeOnMe/spring/domain/enums/UserGender.java deleted file mode 100644 index 4848958..0000000 --- a/src/main/java/PerfumeOnMe/spring/domain/enums/UserGender.java +++ /dev/null @@ -1,5 +0,0 @@ -package PerfumeOnMe.spring.domain.enums; - -public enum UserGender { - MALE, FEMALE, NONE -} diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/UserTerms.java b/src/main/java/PerfumeOnMe/spring/domain/mapping/UserTerms.java deleted file mode 100644 index 6b13c94..0000000 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/UserTerms.java +++ /dev/null @@ -1,47 +0,0 @@ -package PerfumeOnMe.spring.domain.mapping; - -import org.hibernate.annotations.DynamicInsert; -import org.hibernate.annotations.DynamicUpdate; - -import PerfumeOnMe.spring.domain.Terms; -import PerfumeOnMe.spring.domain.User; -import PerfumeOnMe.spring.domain.base.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; - -@Entity -@Getter -@Builder -@NoArgsConstructor(access = AccessLevel.PROTECTED) -@AllArgsConstructor -@DynamicInsert -@DynamicUpdate -@Table(name = "user_terms") -public class UserTerms extends BaseEntity { - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "user_id") - private User user; - - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "terms_id") - private Terms terms; - - @Column(nullable = false) - private boolean agreement; -} diff --git a/src/main/java/PerfumeOnMe/spring/service/external/FastApiClient.java b/src/main/java/PerfumeOnMe/spring/external/fastapi/FastApiClient.java similarity index 88% rename from src/main/java/PerfumeOnMe/spring/service/external/FastApiClient.java rename to src/main/java/PerfumeOnMe/spring/external/fastapi/FastApiClient.java index 2155a97..24f15cc 100644 --- a/src/main/java/PerfumeOnMe/spring/service/external/FastApiClient.java +++ b/src/main/java/PerfumeOnMe/spring/external/fastapi/FastApiClient.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service.external; +package PerfumeOnMe.spring.external.fastapi; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; @@ -11,9 +11,9 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.web.dto.external.FastApiPbtiRecommendResponse; -import PerfumeOnMe.spring.web.dto.external.FastApiRecommendRequest; -import PerfumeOnMe.spring.web.dto.external.FastApiRecommendResponse; +import PerfumeOnMe.spring.external.fastapi.dto.FastApiPbtiRecommendResponse; +import PerfumeOnMe.spring.external.fastapi.dto.FastApiRecommendRequest; +import PerfumeOnMe.spring.external.fastapi.dto.FastApiRecommendResponse; import lombok.RequiredArgsConstructor; @Component diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiPbtiRecommendResponse.java b/src/main/java/PerfumeOnMe/spring/external/fastapi/dto/FastApiPbtiRecommendResponse.java similarity index 96% rename from src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiPbtiRecommendResponse.java rename to src/main/java/PerfumeOnMe/spring/external/fastapi/dto/FastApiPbtiRecommendResponse.java index 3f4c1f8..26929c6 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiPbtiRecommendResponse.java +++ b/src/main/java/PerfumeOnMe/spring/external/fastapi/dto/FastApiPbtiRecommendResponse.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.dto.external; +package PerfumeOnMe.spring.external.fastapi.dto; import java.util.Collections; import java.util.List; diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiRecommendRequest.java b/src/main/java/PerfumeOnMe/spring/external/fastapi/dto/FastApiRecommendRequest.java similarity index 94% rename from src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiRecommendRequest.java rename to src/main/java/PerfumeOnMe/spring/external/fastapi/dto/FastApiRecommendRequest.java index 3f497c9..a6171db 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiRecommendRequest.java +++ b/src/main/java/PerfumeOnMe/spring/external/fastapi/dto/FastApiRecommendRequest.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.dto.external; +package PerfumeOnMe.spring.external.fastapi.dto; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiRecommendResponse.java b/src/main/java/PerfumeOnMe/spring/external/fastapi/dto/FastApiRecommendResponse.java similarity index 91% rename from src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiRecommendResponse.java rename to src/main/java/PerfumeOnMe/spring/external/fastapi/dto/FastApiRecommendResponse.java index 79e743a..b2a4adc 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/external/FastApiRecommendResponse.java +++ b/src/main/java/PerfumeOnMe/spring/external/fastapi/dto/FastApiRecommendResponse.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.dto.external; +package PerfumeOnMe.spring.external.fastapi.dto; import java.util.List; diff --git a/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiApiClient.java b/src/main/java/PerfumeOnMe/spring/external/openai/OpenAiApiClient.java similarity index 91% rename from src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiApiClient.java rename to src/main/java/PerfumeOnMe/spring/external/openai/OpenAiApiClient.java index b72ec7e..9d571a6 100644 --- a/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiApiClient.java +++ b/src/main/java/PerfumeOnMe/spring/external/openai/OpenAiApiClient.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service.openAi; +package PerfumeOnMe.spring.external.openai; import java.util.List; @@ -10,8 +10,8 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.web.dto.Pbti.ChatGptRequest; -import PerfumeOnMe.spring.web.dto.Pbti.ChatGptResponse; +import PerfumeOnMe.spring.pbti.web.dto.ChatGptRequest; +import PerfumeOnMe.spring.pbti.web.dto.ChatGptResponse; import lombok.RequiredArgsConstructor; import reactor.core.publisher.Mono; diff --git a/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiService.java b/src/main/java/PerfumeOnMe/spring/external/openai/OpenAiService.java similarity index 87% rename from src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiService.java rename to src/main/java/PerfumeOnMe/spring/external/openai/OpenAiService.java index da4070a..3130ed8 100644 --- a/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiService.java +++ b/src/main/java/PerfumeOnMe/spring/external/openai/OpenAiService.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service.openAi; +package PerfumeOnMe.spring.external.openai; public interface OpenAiService { diff --git a/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiServiceImpl.java b/src/main/java/PerfumeOnMe/spring/external/openai/OpenAiServiceImpl.java similarity index 83% rename from src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiServiceImpl.java rename to src/main/java/PerfumeOnMe/spring/external/openai/OpenAiServiceImpl.java index 9856f2b..8dee343 100644 --- a/src/main/java/PerfumeOnMe/spring/service/openAi/OpenAiServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/external/openai/OpenAiServiceImpl.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service.openAi; +package PerfumeOnMe.spring.external.openai; import java.nio.file.Files; import java.nio.file.Paths; @@ -22,16 +22,16 @@ public class OpenAiServiceImpl implements OpenAiService { public String getStructuredResponse(String prompt) { return openAiApiClient.callChatGPT(prompt); // 실제 GPT 호출 로직 구현 } - + @Override - public String generateWorkshopResult(String topNote, Long topNoteVolume, - String middleNote, Long middleNoteVolume, - String baseNote, Long baseNoteVolume) { + public String generateWorkshopResult(String topNote, Long topNoteVolume, + String middleNote, Long middleNoteVolume, + String baseNote, Long baseNoteVolume) { try { // 향수공방 프롬프트 파일 읽기 ClassPathResource resource = new ClassPathResource("prompts/workshop.txt"); String promptTemplate = Files.readString(Paths.get(resource.getURI())); - + // 프롬프트에 사용자 입력값 주입 String prompt = promptTemplate .replace("{topNoteType}", topNote) @@ -40,16 +40,16 @@ public String generateWorkshopResult(String topNote, Long topNoteVolume, .replace("{middleNoteVolume}", String.valueOf(middleNoteVolume)) .replace("{baseNoteType}", baseNote) .replace("{baseNoteVolume}", String.valueOf(baseNoteVolume)); - - log.info("향수공방 GPT 요청: topNote={} ({}), middleNote={} ({}), baseNote={} ({})", - topNote, topNoteVolume, middleNote, middleNoteVolume, baseNote, baseNoteVolume); - + + log.info("향수공방 GPT 요청: topNote={} ({}), middleNote={} ({}), baseNote={} ({})", + topNote, topNoteVolume, middleNote, middleNoteVolume, baseNote, baseNoteVolume); + // GPT API 호출 String result = openAiApiClient.callChatGPT(prompt); - + log.info("향수공방 GPT 응답 생성 완료"); return result; - + } catch (Exception e) { log.error("향수공방 GPT 응답 생성 중 오류 발생: {}", e.getMessage(), e); throw new RuntimeException("향수공방 결과 생성에 실패했습니다.", e); diff --git a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java b/src/main/java/PerfumeOnMe/spring/fragrance/converter/FragranceConverter.java similarity index 88% rename from src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java rename to src/main/java/PerfumeOnMe/spring/fragrance/converter/FragranceConverter.java index 8ab1e22..31a6777 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/FragranceConverter.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/converter/FragranceConverter.java @@ -1,22 +1,22 @@ -package PerfumeOnMe.spring.converter; +package PerfumeOnMe.spring.fragrance.converter; import java.util.Comparator; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; -import PerfumeOnMe.spring.domain.Fragrance; -import PerfumeOnMe.spring.domain.Location; -import PerfumeOnMe.spring.domain.Note; -import PerfumeOnMe.spring.domain.Season; -import PerfumeOnMe.spring.domain.mapping.FragranceBaseNote; -import PerfumeOnMe.spring.domain.mapping.FragranceLocation; -import PerfumeOnMe.spring.domain.mapping.FragranceMiddleNote; -import PerfumeOnMe.spring.domain.mapping.FragrancePrice; -import PerfumeOnMe.spring.domain.mapping.FragranceSeason; -import PerfumeOnMe.spring.domain.mapping.FragranceTopNote; -import PerfumeOnMe.spring.domain.mapping.UserFragrance; -import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; +import PerfumeOnMe.spring.fragrance.domain.Fragrance; +import PerfumeOnMe.spring.fragrance.domain.Location; +import PerfumeOnMe.spring.fragrance.domain.Note; +import PerfumeOnMe.spring.fragrance.domain.Season; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceBaseNote; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceLocation; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceMiddleNote; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragrancePrice; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceSeason; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceTopNote; +import PerfumeOnMe.spring.fragrance.web.dto.FragranceResponseDTO; +import PerfumeOnMe.spring.user.domain.mapping.UserFragrance; public class FragranceConverter { diff --git a/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java b/src/main/java/PerfumeOnMe/spring/fragrance/domain/Fragrance.java similarity index 80% rename from src/main/java/PerfumeOnMe/spring/domain/Fragrance.java rename to src/main/java/PerfumeOnMe/spring/fragrance/domain/Fragrance.java index 54d1f66..94242e6 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Fragrance.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/domain/Fragrance.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.domain; +package PerfumeOnMe.spring.fragrance.domain; import java.util.ArrayList; import java.util.List; @@ -6,18 +6,18 @@ import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import PerfumeOnMe.spring.domain.base.BaseEntity; -import PerfumeOnMe.spring.domain.enums.Brand; -import PerfumeOnMe.spring.domain.enums.FragranceGender; -import PerfumeOnMe.spring.domain.enums.FragranceType; -import PerfumeOnMe.spring.domain.mapping.FragranceBaseNote; -import PerfumeOnMe.spring.domain.mapping.FragranceLocation; -import PerfumeOnMe.spring.domain.mapping.FragranceMiddleNote; -import PerfumeOnMe.spring.domain.mapping.FragrancePrice; -import PerfumeOnMe.spring.domain.mapping.FragranceSeason; -import PerfumeOnMe.spring.domain.mapping.FragranceTopNote; -import PerfumeOnMe.spring.domain.mapping.RecommendedFragrance; -import PerfumeOnMe.spring.domain.mapping.UserFragrance; +import PerfumeOnMe.spring.common.base.BaseEntity; +import PerfumeOnMe.spring.common.enums.Brand; +import PerfumeOnMe.spring.common.enums.FragranceGender; +import PerfumeOnMe.spring.common.enums.FragranceType; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceBaseNote; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceLocation; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceMiddleNote; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragrancePrice; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceSeason; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceTopNote; +import PerfumeOnMe.spring.fragrance.domain.mapping.RecommendedFragrance; +import PerfumeOnMe.spring.user.domain.mapping.UserFragrance; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/src/main/java/PerfumeOnMe/spring/domain/Location.java b/src/main/java/PerfumeOnMe/spring/fragrance/domain/Location.java similarity index 86% rename from src/main/java/PerfumeOnMe/spring/domain/Location.java rename to src/main/java/PerfumeOnMe/spring/fragrance/domain/Location.java index eb56eb2..9c46bff 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Location.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/domain/Location.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.domain; +package PerfumeOnMe.spring.fragrance.domain; import java.util.ArrayList; import java.util.List; @@ -6,8 +6,8 @@ import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import PerfumeOnMe.spring.domain.base.BaseEntity; -import PerfumeOnMe.spring.domain.mapping.FragranceLocation; +import PerfumeOnMe.spring.common.base.BaseEntity; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceLocation; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/src/main/java/PerfumeOnMe/spring/domain/Note.java b/src/main/java/PerfumeOnMe/spring/fragrance/domain/Note.java similarity index 82% rename from src/main/java/PerfumeOnMe/spring/domain/Note.java rename to src/main/java/PerfumeOnMe/spring/fragrance/domain/Note.java index 99a8bb3..cdc3415 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Note.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/domain/Note.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.domain; +package PerfumeOnMe.spring.fragrance.domain; import java.util.ArrayList; import java.util.List; @@ -6,12 +6,12 @@ import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import PerfumeOnMe.spring.domain.base.BaseEntity; -import PerfumeOnMe.spring.domain.enums.NoteType; -import PerfumeOnMe.spring.domain.mapping.FragranceBaseNote; -import PerfumeOnMe.spring.domain.mapping.FragranceMiddleNote; -import PerfumeOnMe.spring.domain.mapping.FragranceTopNote; -import PerfumeOnMe.spring.domain.mapping.UserNote; +import PerfumeOnMe.spring.common.base.BaseEntity; +import PerfumeOnMe.spring.common.enums.NoteType; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceBaseNote; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceMiddleNote; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceTopNote; +import PerfumeOnMe.spring.user.domain.mapping.UserNote; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/src/main/java/PerfumeOnMe/spring/domain/Price.java b/src/main/java/PerfumeOnMe/spring/fragrance/domain/Price.java similarity index 87% rename from src/main/java/PerfumeOnMe/spring/domain/Price.java rename to src/main/java/PerfumeOnMe/spring/fragrance/domain/Price.java index f362270..1e4865c 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Price.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/domain/Price.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.domain; +package PerfumeOnMe.spring.fragrance.domain; import java.util.ArrayList; import java.util.List; @@ -6,8 +6,8 @@ import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import PerfumeOnMe.spring.domain.base.BaseEntity; -import PerfumeOnMe.spring.domain.mapping.FragrancePrice; +import PerfumeOnMe.spring.common.base.BaseEntity; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragrancePrice; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/src/main/java/PerfumeOnMe/spring/domain/Season.java b/src/main/java/PerfumeOnMe/spring/fragrance/domain/Season.java similarity index 86% rename from src/main/java/PerfumeOnMe/spring/domain/Season.java rename to src/main/java/PerfumeOnMe/spring/fragrance/domain/Season.java index 8427be4..77fc6aa 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Season.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/domain/Season.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.domain; +package PerfumeOnMe.spring.fragrance.domain; import java.util.ArrayList; import java.util.List; @@ -6,8 +6,8 @@ import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import PerfumeOnMe.spring.domain.base.BaseEntity; -import PerfumeOnMe.spring.domain.mapping.FragranceSeason; +import PerfumeOnMe.spring.common.base.BaseEntity; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceSeason; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceBaseNote.java b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceBaseNote.java similarity index 83% rename from src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceBaseNote.java rename to src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceBaseNote.java index 16b2e3a..5e80a19 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceBaseNote.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceBaseNote.java @@ -1,11 +1,11 @@ -package PerfumeOnMe.spring.domain.mapping; +package PerfumeOnMe.spring.fragrance.domain.mapping; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import PerfumeOnMe.spring.domain.Fragrance; -import PerfumeOnMe.spring.domain.Note; -import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.common.base.BaseEntity; +import PerfumeOnMe.spring.fragrance.domain.Fragrance; +import PerfumeOnMe.spring.fragrance.domain.Note; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceLocation.java b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceLocation.java similarity index 83% rename from src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceLocation.java rename to src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceLocation.java index a46db2a..799d452 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceLocation.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceLocation.java @@ -1,11 +1,11 @@ -package PerfumeOnMe.spring.domain.mapping; +package PerfumeOnMe.spring.fragrance.domain.mapping; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import PerfumeOnMe.spring.domain.Fragrance; -import PerfumeOnMe.spring.domain.Location; -import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.common.base.BaseEntity; +import PerfumeOnMe.spring.fragrance.domain.Fragrance; +import PerfumeOnMe.spring.fragrance.domain.Location; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceMiddleNote.java b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceMiddleNote.java similarity index 83% rename from src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceMiddleNote.java rename to src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceMiddleNote.java index f1b073f..60b3e7a 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceMiddleNote.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceMiddleNote.java @@ -1,11 +1,11 @@ -package PerfumeOnMe.spring.domain.mapping; +package PerfumeOnMe.spring.fragrance.domain.mapping; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import PerfumeOnMe.spring.domain.Fragrance; -import PerfumeOnMe.spring.domain.Note; -import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.common.base.BaseEntity; +import PerfumeOnMe.spring.fragrance.domain.Fragrance; +import PerfumeOnMe.spring.fragrance.domain.Note; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragrancePrice.java b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragrancePrice.java similarity index 83% rename from src/main/java/PerfumeOnMe/spring/domain/mapping/FragrancePrice.java rename to src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragrancePrice.java index abf48b1..44e095f 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragrancePrice.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragrancePrice.java @@ -1,11 +1,11 @@ -package PerfumeOnMe.spring.domain.mapping; +package PerfumeOnMe.spring.fragrance.domain.mapping; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import PerfumeOnMe.spring.domain.Fragrance; -import PerfumeOnMe.spring.domain.Price; -import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.common.base.BaseEntity; +import PerfumeOnMe.spring.fragrance.domain.Fragrance; +import PerfumeOnMe.spring.fragrance.domain.Price; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceSeason.java b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceSeason.java similarity index 83% rename from src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceSeason.java rename to src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceSeason.java index bea6524..53d72c2 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceSeason.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceSeason.java @@ -1,11 +1,11 @@ -package PerfumeOnMe.spring.domain.mapping; +package PerfumeOnMe.spring.fragrance.domain.mapping; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import PerfumeOnMe.spring.domain.Fragrance; -import PerfumeOnMe.spring.domain.Season; -import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.common.base.BaseEntity; +import PerfumeOnMe.spring.fragrance.domain.Fragrance; +import PerfumeOnMe.spring.fragrance.domain.Season; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceTopNote.java b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceTopNote.java similarity index 83% rename from src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceTopNote.java rename to src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceTopNote.java index a7e5e5a..e08c7ad 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/FragranceTopNote.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceTopNote.java @@ -1,11 +1,11 @@ -package PerfumeOnMe.spring.domain.mapping; +package PerfumeOnMe.spring.fragrance.domain.mapping; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import PerfumeOnMe.spring.domain.Fragrance; -import PerfumeOnMe.spring.domain.Note; -import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.common.base.BaseEntity; +import PerfumeOnMe.spring.fragrance.domain.Fragrance; +import PerfumeOnMe.spring.fragrance.domain.Note; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/RecommendedFragrance.java b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/RecommendedFragrance.java similarity index 84% rename from src/main/java/PerfumeOnMe/spring/domain/mapping/RecommendedFragrance.java rename to src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/RecommendedFragrance.java index 510be79..558f8dd 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/RecommendedFragrance.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/RecommendedFragrance.java @@ -1,11 +1,11 @@ -package PerfumeOnMe.spring.domain.mapping; +package PerfumeOnMe.spring.fragrance.domain.mapping; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import PerfumeOnMe.spring.domain.Fragrance; -import PerfumeOnMe.spring.domain.PBTI; -import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.common.base.BaseEntity; +import PerfumeOnMe.spring.fragrance.domain.Fragrance; +import PerfumeOnMe.spring.pbti.domain.PBTI; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepository.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepository.java similarity index 94% rename from src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepository.java rename to src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepository.java index adedd12..c6ab48e 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepository.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepository.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.repository.fragrance; +package PerfumeOnMe.spring.fragrance.repository; import java.util.List; import java.util.Optional; @@ -7,7 +7,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import PerfumeOnMe.spring.domain.Fragrance; +import PerfumeOnMe.spring.fragrance.domain.Fragrance; public interface FragranceRepository extends JpaRepository, FragranceRepositoryCustom { Optional findByName(String name); diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepositoryCustom.java similarity index 73% rename from src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java rename to src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepositoryCustom.java index 1c443db..1309194 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryCustom.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepositoryCustom.java @@ -1,12 +1,12 @@ -package PerfumeOnMe.spring.repository.fragrance; +package PerfumeOnMe.spring.fragrance.repository; import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import PerfumeOnMe.spring.domain.Fragrance; -import PerfumeOnMe.spring.web.dto.fragrance.FragranceRequestDTO; +import PerfumeOnMe.spring.fragrance.domain.Fragrance; +import PerfumeOnMe.spring.fragrance.web.dto.FragranceRequestDTO; public interface FragranceRepositoryCustom { // 향수 상세 diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepositoryImpl.java similarity index 85% rename from src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java rename to src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepositoryImpl.java index fc76795..6b72cb7 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrance/FragranceRepositoryImpl.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepositoryImpl.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.repository.fragrance; +package PerfumeOnMe.spring.fragrance.repository; import java.util.List; import java.util.Optional; @@ -14,21 +14,21 @@ import com.querydsl.core.types.dsl.StringPath; import com.querydsl.jpa.impl.JPAQueryFactory; -import PerfumeOnMe.spring.domain.Fragrance; -import PerfumeOnMe.spring.domain.QFragrance; -import PerfumeOnMe.spring.domain.QLocation; -import PerfumeOnMe.spring.domain.QNote; -import PerfumeOnMe.spring.domain.QPrice; -import PerfumeOnMe.spring.domain.QSeason; -import PerfumeOnMe.spring.domain.enums.FragranceGender; -import PerfumeOnMe.spring.domain.enums.FragranceType; -import PerfumeOnMe.spring.domain.mapping.QFragranceBaseNote; -import PerfumeOnMe.spring.domain.mapping.QFragranceLocation; -import PerfumeOnMe.spring.domain.mapping.QFragranceMiddleNote; -import PerfumeOnMe.spring.domain.mapping.QFragrancePrice; -import PerfumeOnMe.spring.domain.mapping.QFragranceSeason; -import PerfumeOnMe.spring.domain.mapping.QFragranceTopNote; -import PerfumeOnMe.spring.web.dto.fragrance.FragranceRequestDTO; +import PerfumeOnMe.spring.common.enums.FragranceGender; +import PerfumeOnMe.spring.common.enums.FragranceType; +import PerfumeOnMe.spring.fragrance.domain.Fragrance; +import PerfumeOnMe.spring.fragrance.domain.QFragrance; +import PerfumeOnMe.spring.fragrance.domain.QLocation; +import PerfumeOnMe.spring.fragrance.domain.QNote; +import PerfumeOnMe.spring.fragrance.domain.QPrice; +import PerfumeOnMe.spring.fragrance.domain.QSeason; +import PerfumeOnMe.spring.fragrance.domain.mapping.QFragranceBaseNote; +import PerfumeOnMe.spring.fragrance.domain.mapping.QFragranceLocation; +import PerfumeOnMe.spring.fragrance.domain.mapping.QFragranceMiddleNote; +import PerfumeOnMe.spring.fragrance.domain.mapping.QFragrancePrice; +import PerfumeOnMe.spring.fragrance.domain.mapping.QFragranceSeason; +import PerfumeOnMe.spring.fragrance.domain.mapping.QFragranceTopNote; +import PerfumeOnMe.spring.fragrance.web.dto.FragranceRequestDTO; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceBaseNote/FragranceBaseNoteRepository.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceBaseNote/FragranceBaseNoteRepository.java similarity index 58% rename from src/main/java/PerfumeOnMe/spring/repository/fragranceBaseNote/FragranceBaseNoteRepository.java rename to src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceBaseNote/FragranceBaseNoteRepository.java index 6417c67..a1a9758 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragranceBaseNote/FragranceBaseNoteRepository.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceBaseNote/FragranceBaseNoteRepository.java @@ -1,8 +1,8 @@ -package PerfumeOnMe.spring.repository.fragranceBaseNote; +package PerfumeOnMe.spring.fragrance.repository.fragranceBaseNote; import org.springframework.data.jpa.repository.JpaRepository; -import PerfumeOnMe.spring.domain.mapping.FragranceBaseNote; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceBaseNote; public interface FragranceBaseNoteRepository extends JpaRepository, FragranceBaseNoteRepositoryCustom { diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceBaseNote/FragranceBaseNoteRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceBaseNote/FragranceBaseNoteRepositoryCustom.java new file mode 100644 index 0000000..866d078 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceBaseNote/FragranceBaseNoteRepositoryCustom.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.fragrance.repository.fragranceBaseNote; + +public interface FragranceBaseNoteRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceBaseNote/FragranceBaseNoteRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceBaseNote/FragranceBaseNoteRepositoryImpl.java similarity index 76% rename from src/main/java/PerfumeOnMe/spring/repository/fragranceBaseNote/FragranceBaseNoteRepositoryImpl.java rename to src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceBaseNote/FragranceBaseNoteRepositoryImpl.java index 81856b5..9fbe277 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragranceBaseNote/FragranceBaseNoteRepositoryImpl.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceBaseNote/FragranceBaseNoteRepositoryImpl.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.repository.fragranceBaseNote; +package PerfumeOnMe.spring.fragrance.repository.fragranceBaseNote; import org.springframework.stereotype.Repository; diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceLocation/FragranceLocationRepository.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceLocation/FragranceLocationRepository.java similarity index 59% rename from src/main/java/PerfumeOnMe/spring/repository/fragranceLocation/FragranceLocationRepository.java rename to src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceLocation/FragranceLocationRepository.java index 73b487b..3815f30 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragranceLocation/FragranceLocationRepository.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceLocation/FragranceLocationRepository.java @@ -1,8 +1,8 @@ -package PerfumeOnMe.spring.repository.fragranceLocation; +package PerfumeOnMe.spring.fragrance.repository.fragranceLocation; import org.springframework.data.jpa.repository.JpaRepository; -import PerfumeOnMe.spring.domain.mapping.FragranceLocation; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceLocation; public interface FragranceLocationRepository extends JpaRepository, FragranceLocationRepositoryCustom { diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceLocation/FragranceLocationRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceLocation/FragranceLocationRepositoryCustom.java new file mode 100644 index 0000000..12aed5e --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceLocation/FragranceLocationRepositoryCustom.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.fragrance.repository.fragranceLocation; + +public interface FragranceLocationRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceLocation/FragranceLocationRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceLocation/FragranceLocationRepositoryImpl.java similarity index 76% rename from src/main/java/PerfumeOnMe/spring/repository/fragranceLocation/FragranceLocationRepositoryImpl.java rename to src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceLocation/FragranceLocationRepositoryImpl.java index 8b43a74..a358bbd 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragranceLocation/FragranceLocationRepositoryImpl.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceLocation/FragranceLocationRepositoryImpl.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.repository.fragranceLocation; +package PerfumeOnMe.spring.fragrance.repository.fragranceLocation; import org.springframework.stereotype.Repository; diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceMiddleNote/FragranceMiddleNoteRepository.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceMiddleNote/FragranceMiddleNoteRepository.java similarity index 59% rename from src/main/java/PerfumeOnMe/spring/repository/fragranceMiddleNote/FragranceMiddleNoteRepository.java rename to src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceMiddleNote/FragranceMiddleNoteRepository.java index 2767fd4..1eaaf09 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragranceMiddleNote/FragranceMiddleNoteRepository.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceMiddleNote/FragranceMiddleNoteRepository.java @@ -1,8 +1,8 @@ -package PerfumeOnMe.spring.repository.fragranceMiddleNote; +package PerfumeOnMe.spring.fragrance.repository.fragranceMiddleNote; import org.springframework.data.jpa.repository.JpaRepository; -import PerfumeOnMe.spring.domain.mapping.FragranceMiddleNote; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceMiddleNote; public interface FragranceMiddleNoteRepository extends JpaRepository, FragranceMiddleNoteRepositoryCustom { diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryCustom.java new file mode 100644 index 0000000..d105cf7 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryCustom.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.fragrance.repository.fragranceMiddleNote; + +public interface FragranceMiddleNoteRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryImpl.java similarity index 76% rename from src/main/java/PerfumeOnMe/spring/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryImpl.java rename to src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryImpl.java index d484de3..d933872 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryImpl.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryImpl.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.repository.fragranceMiddleNote; +package PerfumeOnMe.spring.fragrance.repository.fragranceMiddleNote; import org.springframework.stereotype.Repository; diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepository.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragrancePrice/FragrancePriceRepository.java similarity index 52% rename from src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepository.java rename to src/main/java/PerfumeOnMe/spring/fragrance/repository/fragrancePrice/FragrancePriceRepository.java index f7d32dd..009910d 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepository.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragrancePrice/FragrancePriceRepository.java @@ -1,10 +1,10 @@ -package PerfumeOnMe.spring.repository.fragrancePrice; +package PerfumeOnMe.spring.fragrance.repository.fragrancePrice; import org.springframework.data.jpa.repository.JpaRepository; -import PerfumeOnMe.spring.domain.Fragrance; -import PerfumeOnMe.spring.domain.Price; -import PerfumeOnMe.spring.domain.mapping.FragrancePrice; +import PerfumeOnMe.spring.fragrance.domain.Fragrance; +import PerfumeOnMe.spring.fragrance.domain.Price; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragrancePrice; public interface FragrancePriceRepository extends JpaRepository, FragrancePriceRepositoryCustom { boolean existsByFragranceAndPrice(Fragrance fragrance, Price price); diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragrancePrice/FragrancePriceRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragrancePrice/FragrancePriceRepositoryCustom.java new file mode 100644 index 0000000..5b9fa7e --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragrancePrice/FragrancePriceRepositoryCustom.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.fragrance.repository.fragrancePrice; + +public interface FragrancePriceRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragrancePrice/FragrancePriceRepositoryImpl.java similarity index 77% rename from src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepositoryImpl.java rename to src/main/java/PerfumeOnMe/spring/fragrance/repository/fragrancePrice/FragrancePriceRepositoryImpl.java index 7756a39..e8f0494 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepositoryImpl.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragrancePrice/FragrancePriceRepositoryImpl.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.repository.fragrancePrice; +package PerfumeOnMe.spring.fragrance.repository.fragrancePrice; import org.springframework.stereotype.Repository; diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceSeason/FragranceSeasonRepository.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceSeason/FragranceSeasonRepository.java similarity index 58% rename from src/main/java/PerfumeOnMe/spring/repository/fragranceSeason/FragranceSeasonRepository.java rename to src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceSeason/FragranceSeasonRepository.java index 363e371..1fb823d 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragranceSeason/FragranceSeasonRepository.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceSeason/FragranceSeasonRepository.java @@ -1,8 +1,8 @@ -package PerfumeOnMe.spring.repository.fragranceSeason; +package PerfumeOnMe.spring.fragrance.repository.fragranceSeason; import org.springframework.data.jpa.repository.JpaRepository; -import PerfumeOnMe.spring.domain.mapping.FragranceSeason; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceSeason; public interface FragranceSeasonRepository extends JpaRepository, FragranceSeasonRepositoryCustom { diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceSeason/FragranceSeasonRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceSeason/FragranceSeasonRepositoryCustom.java new file mode 100644 index 0000000..fa07f23 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceSeason/FragranceSeasonRepositoryCustom.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.fragrance.repository.fragranceSeason; + +public interface FragranceSeasonRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceSeason/FragranceSeasonRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceSeason/FragranceSeasonRepositoryImpl.java similarity index 77% rename from src/main/java/PerfumeOnMe/spring/repository/fragranceSeason/FragranceSeasonRepositoryImpl.java rename to src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceSeason/FragranceSeasonRepositoryImpl.java index 4d426de..31e8a30 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragranceSeason/FragranceSeasonRepositoryImpl.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceSeason/FragranceSeasonRepositoryImpl.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.repository.fragranceSeason; +package PerfumeOnMe.spring.fragrance.repository.fragranceSeason; import org.springframework.stereotype.Repository; diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceTopNote/FragranceTopNoteRepository.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceTopNote/FragranceTopNoteRepository.java similarity index 58% rename from src/main/java/PerfumeOnMe/spring/repository/fragranceTopNote/FragranceTopNoteRepository.java rename to src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceTopNote/FragranceTopNoteRepository.java index 127277c..49eeeb1 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragranceTopNote/FragranceTopNoteRepository.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceTopNote/FragranceTopNoteRepository.java @@ -1,8 +1,8 @@ -package PerfumeOnMe.spring.repository.fragranceTopNote; +package PerfumeOnMe.spring.fragrance.repository.fragranceTopNote; import org.springframework.data.jpa.repository.JpaRepository; -import PerfumeOnMe.spring.domain.mapping.FragranceTopNote; +import PerfumeOnMe.spring.fragrance.domain.mapping.FragranceTopNote; public interface FragranceTopNoteRepository extends JpaRepository, FragranceTopNoteRepositoryCustom { diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceTopNote/FragranceTopNoteRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceTopNote/FragranceTopNoteRepositoryCustom.java new file mode 100644 index 0000000..4a68e15 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceTopNote/FragranceTopNoteRepositoryCustom.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.fragrance.repository.fragranceTopNote; + +public interface FragranceTopNoteRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceTopNote/FragranceTopNoteRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceTopNote/FragranceTopNoteRepositoryImpl.java similarity index 77% rename from src/main/java/PerfumeOnMe/spring/repository/fragranceTopNote/FragranceTopNoteRepositoryImpl.java rename to src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceTopNote/FragranceTopNoteRepositoryImpl.java index c1ec8d8..a269bb3 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/fragranceTopNote/FragranceTopNoteRepositoryImpl.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceTopNote/FragranceTopNoteRepositoryImpl.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.repository.fragranceTopNote; +package PerfumeOnMe.spring.fragrance.repository.fragranceTopNote; import org.springframework.stereotype.Repository; diff --git a/src/main/java/PerfumeOnMe/spring/repository/location/LocationRepository.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/location/LocationRepository.java similarity index 68% rename from src/main/java/PerfumeOnMe/spring/repository/location/LocationRepository.java rename to src/main/java/PerfumeOnMe/spring/fragrance/repository/location/LocationRepository.java index 5e05d42..b8734fc 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/location/LocationRepository.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/location/LocationRepository.java @@ -1,10 +1,10 @@ -package PerfumeOnMe.spring.repository.location; +package PerfumeOnMe.spring.fragrance.repository.location; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -import PerfumeOnMe.spring.domain.Location; +import PerfumeOnMe.spring.fragrance.domain.Location; public interface LocationRepository extends JpaRepository, LocationRepositoryCustom { Optional findByName(String name); diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/repository/location/LocationRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/location/LocationRepositoryCustom.java new file mode 100644 index 0000000..2c5d9b8 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/location/LocationRepositoryCustom.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.fragrance.repository.location; + +public interface LocationRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/location/LocationRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/location/LocationRepositoryImpl.java similarity index 77% rename from src/main/java/PerfumeOnMe/spring/repository/location/LocationRepositoryImpl.java rename to src/main/java/PerfumeOnMe/spring/fragrance/repository/location/LocationRepositoryImpl.java index 2d95831..d287a86 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/location/LocationRepositoryImpl.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/location/LocationRepositoryImpl.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.repository.location; +package PerfumeOnMe.spring.fragrance.repository.location; import org.springframework.stereotype.Repository; diff --git a/src/main/java/PerfumeOnMe/spring/repository/note/NoteRepository.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/note/NoteRepository.java similarity index 68% rename from src/main/java/PerfumeOnMe/spring/repository/note/NoteRepository.java rename to src/main/java/PerfumeOnMe/spring/fragrance/repository/note/NoteRepository.java index 4a833ad..346574e 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/note/NoteRepository.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/note/NoteRepository.java @@ -1,10 +1,10 @@ -package PerfumeOnMe.spring.repository.note; +package PerfumeOnMe.spring.fragrance.repository.note; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -import PerfumeOnMe.spring.domain.Note; +import PerfumeOnMe.spring.fragrance.domain.Note; public interface NoteRepository extends JpaRepository, NoteRepositoryCustom { Optional findByName(String name); diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/repository/note/NoteRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/note/NoteRepositoryCustom.java new file mode 100644 index 0000000..d716f70 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/note/NoteRepositoryCustom.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.fragrance.repository.note; + +public interface NoteRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/note/NoteRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/note/NoteRepositoryImpl.java similarity index 78% rename from src/main/java/PerfumeOnMe/spring/repository/note/NoteRepositoryImpl.java rename to src/main/java/PerfumeOnMe/spring/fragrance/repository/note/NoteRepositoryImpl.java index c09db9e..f9eb1c2 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/note/NoteRepositoryImpl.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/note/NoteRepositoryImpl.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.repository.note; +package PerfumeOnMe.spring.fragrance.repository.note; import org.springframework.stereotype.Repository; diff --git a/src/main/java/PerfumeOnMe/spring/repository/price/PriceRepository.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/price/PriceRepository.java similarity index 71% rename from src/main/java/PerfumeOnMe/spring/repository/price/PriceRepository.java rename to src/main/java/PerfumeOnMe/spring/fragrance/repository/price/PriceRepository.java index 5e52039..bd962f2 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/price/PriceRepository.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/price/PriceRepository.java @@ -1,10 +1,10 @@ -package PerfumeOnMe.spring.repository.price; +package PerfumeOnMe.spring.fragrance.repository.price; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -import PerfumeOnMe.spring.domain.Price; +import PerfumeOnMe.spring.fragrance.domain.Price; public interface PriceRepository extends JpaRepository, PriceRepositoryCustom { Optional findByMlCountAndPrice(Integer mlCount, Integer price); diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/repository/price/PriceRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/price/PriceRepositoryCustom.java new file mode 100644 index 0000000..d78901c --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/price/PriceRepositoryCustom.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.fragrance.repository.price; + +public interface PriceRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/price/PriceRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/price/PriceRepositoryImpl.java similarity index 78% rename from src/main/java/PerfumeOnMe/spring/repository/price/PriceRepositoryImpl.java rename to src/main/java/PerfumeOnMe/spring/fragrance/repository/price/PriceRepositoryImpl.java index 65c5b4f..612ac8a 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/price/PriceRepositoryImpl.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/price/PriceRepositoryImpl.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.repository.price; +package PerfumeOnMe.spring.fragrance.repository.price; import org.springframework.stereotype.Repository; diff --git a/src/main/java/PerfumeOnMe/spring/repository/season/SeasonRepository.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/season/SeasonRepository.java similarity index 68% rename from src/main/java/PerfumeOnMe/spring/repository/season/SeasonRepository.java rename to src/main/java/PerfumeOnMe/spring/fragrance/repository/season/SeasonRepository.java index 2201726..6b0c961 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/season/SeasonRepository.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/season/SeasonRepository.java @@ -1,10 +1,10 @@ -package PerfumeOnMe.spring.repository.season; +package PerfumeOnMe.spring.fragrance.repository.season; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -import PerfumeOnMe.spring.domain.Season; +import PerfumeOnMe.spring.fragrance.domain.Season; public interface SeasonRepository extends JpaRepository, SeasonRepositoryCustom { Optional findByName(String name); diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/repository/season/SeasonRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/season/SeasonRepositoryCustom.java new file mode 100644 index 0000000..1fdf3c4 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/season/SeasonRepositoryCustom.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.fragrance.repository.season; + +public interface SeasonRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/season/SeasonRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/season/SeasonRepositoryImpl.java similarity index 78% rename from src/main/java/PerfumeOnMe/spring/repository/season/SeasonRepositoryImpl.java rename to src/main/java/PerfumeOnMe/spring/fragrance/repository/season/SeasonRepositoryImpl.java index fcaa1e8..f18c5a8 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/season/SeasonRepositoryImpl.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/season/SeasonRepositoryImpl.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.repository.season; +package PerfumeOnMe.spring.fragrance.repository.season; import org.springframework.stereotype.Repository; diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java b/src/main/java/PerfumeOnMe/spring/fragrance/service/FragranceService.java similarity index 82% rename from src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java rename to src/main/java/PerfumeOnMe/spring/fragrance/service/FragranceService.java index 7f1cc7c..c231dd4 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceService.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/service/FragranceService.java @@ -1,8 +1,8 @@ -package PerfumeOnMe.spring.service.fragrance; +package PerfumeOnMe.spring.fragrance.service; -import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; -import PerfumeOnMe.spring.web.dto.fragrance.FragranceRequestDTO; -import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; +import PerfumeOnMe.spring.fragrance.web.dto.FragranceRequestDTO; +import PerfumeOnMe.spring.fragrance.web.dto.FragranceResponseDTO; +import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; public interface FragranceService { // 향수 상세 API diff --git a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/service/FragranceServiceImpl.java similarity index 89% rename from src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java rename to src/main/java/PerfumeOnMe/spring/fragrance/service/FragranceServiceImpl.java index 2e42939..1415432 100644 --- a/src/main/java/PerfumeOnMe/spring/service/fragrance/FragranceServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/service/FragranceServiceImpl.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service.fragrance; +package PerfumeOnMe.spring.fragrance.service; import java.util.List; import java.util.Optional; @@ -16,26 +16,26 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; -import PerfumeOnMe.spring.converter.FragranceConverter; -import PerfumeOnMe.spring.domain.Fragrance; -import PerfumeOnMe.spring.domain.ImageKeyword; -import PerfumeOnMe.spring.domain.User; -import PerfumeOnMe.spring.domain.Workshop; -import PerfumeOnMe.spring.domain.enums.FragranceGender; -import PerfumeOnMe.spring.domain.enums.FragranceType; -import PerfumeOnMe.spring.domain.enums.UserGender; -import PerfumeOnMe.spring.domain.mapping.UserFragrance; -import PerfumeOnMe.spring.repository.fragrance.FragranceRepository; -import PerfumeOnMe.spring.repository.imagekeyword.ImageKeywordRepository; -import PerfumeOnMe.spring.repository.location.LocationRepository; -import PerfumeOnMe.spring.repository.note.NoteRepository; -import PerfumeOnMe.spring.repository.season.SeasonRepository; -import PerfumeOnMe.spring.repository.user.UserRepository; -import PerfumeOnMe.spring.repository.userFragrance.UserFragranceRepository; -import PerfumeOnMe.spring.repository.workshop.WorkshopRepository; -import PerfumeOnMe.spring.web.dto.fragrance.FragranceRequestDTO; -import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; +import PerfumeOnMe.spring.common.enums.FragranceGender; +import PerfumeOnMe.spring.common.enums.FragranceType; +import PerfumeOnMe.spring.common.enums.UserGender; +import PerfumeOnMe.spring.fragrance.converter.FragranceConverter; +import PerfumeOnMe.spring.fragrance.domain.Fragrance; +import PerfumeOnMe.spring.fragrance.repository.FragranceRepository; +import PerfumeOnMe.spring.fragrance.repository.location.LocationRepository; +import PerfumeOnMe.spring.fragrance.repository.note.NoteRepository; +import PerfumeOnMe.spring.fragrance.repository.season.SeasonRepository; +import PerfumeOnMe.spring.fragrance.web.dto.FragranceRequestDTO; +import PerfumeOnMe.spring.fragrance.web.dto.FragranceResponseDTO; +import PerfumeOnMe.spring.imagekeyword.domain.ImageKeyword; +import PerfumeOnMe.spring.imagekeyword.repository.ImageKeywordRepository; +import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; +import PerfumeOnMe.spring.user.domain.User; +import PerfumeOnMe.spring.user.domain.mapping.UserFragrance; +import PerfumeOnMe.spring.user.repository.UserRepository; +import PerfumeOnMe.spring.user.repository.userFragrance.UserFragranceRepository; +import PerfumeOnMe.spring.workshop.domain.Workshop; +import PerfumeOnMe.spring.workshop.repository.WorkshopRepository; import lombok.RequiredArgsConstructor; @Service diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidKeyword.java b/src/main/java/PerfumeOnMe/spring/fragrance/validation/annotation/ValidKeyword.java similarity index 81% rename from src/main/java/PerfumeOnMe/spring/validation/annotation/ValidKeyword.java rename to src/main/java/PerfumeOnMe/spring/fragrance/validation/annotation/ValidKeyword.java index 8aa4a45..5e64435 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidKeyword.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/validation/annotation/ValidKeyword.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.validation.annotation; +package PerfumeOnMe.spring.fragrance.validation.annotation; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; @@ -6,7 +6,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import PerfumeOnMe.spring.validation.validator.ValidKeywordValidator; +import PerfumeOnMe.spring.fragrance.validation.validator.ValidKeywordValidator; import jakarta.validation.Constraint; import jakarta.validation.Payload; diff --git a/src/main/java/PerfumeOnMe/spring/validation/validator/ValidKeywordValidator.java b/src/main/java/PerfumeOnMe/spring/fragrance/validation/validator/ValidKeywordValidator.java similarity index 75% rename from src/main/java/PerfumeOnMe/spring/validation/validator/ValidKeywordValidator.java rename to src/main/java/PerfumeOnMe/spring/fragrance/validation/validator/ValidKeywordValidator.java index 5074737..530f6d8 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/validator/ValidKeywordValidator.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/validation/validator/ValidKeywordValidator.java @@ -1,6 +1,6 @@ -package PerfumeOnMe.spring.validation.validator; +package PerfumeOnMe.spring.fragrance.validation.validator; -import PerfumeOnMe.spring.validation.annotation.ValidKeyword; +import PerfumeOnMe.spring.fragrance.validation.annotation.ValidKeyword; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java b/src/main/java/PerfumeOnMe/spring/fragrance/web/controller/FragranceController.java similarity index 97% rename from src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java rename to src/main/java/PerfumeOnMe/spring/fragrance/web/controller/FragranceController.java index 30c7ccd..d5f4826 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/FragranceController.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/web/controller/FragranceController.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.controller; +package PerfumeOnMe.spring.fragrance.web.controller; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -11,10 +11,10 @@ import org.springframework.web.bind.annotation.RestController; import PerfumeOnMe.spring.apiPayload.ApiResponse; -import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; -import PerfumeOnMe.spring.service.fragrance.FragranceService; -import PerfumeOnMe.spring.web.dto.fragrance.FragranceRequestDTO; -import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; +import PerfumeOnMe.spring.fragrance.service.FragranceService; +import PerfumeOnMe.spring.fragrance.web.dto.FragranceRequestDTO; +import PerfumeOnMe.spring.fragrance.web.dto.FragranceResponseDTO; +import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameters; diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceRequestDTO.java b/src/main/java/PerfumeOnMe/spring/fragrance/web/dto/FragranceRequestDTO.java similarity index 77% rename from src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceRequestDTO.java rename to src/main/java/PerfumeOnMe/spring/fragrance/web/dto/FragranceRequestDTO.java index 4cc48e9..291b266 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/web/dto/FragranceRequestDTO.java @@ -1,8 +1,8 @@ -package PerfumeOnMe.spring.web.dto.fragrance; +package PerfumeOnMe.spring.fragrance.web.dto; -import PerfumeOnMe.spring.validation.annotation.ValidKeyword; -import PerfumeOnMe.spring.validation.annotation.ValidPage; -import PerfumeOnMe.spring.validation.annotation.ValidSize; +import PerfumeOnMe.spring.common.validation.annotation.ValidPage; +import PerfumeOnMe.spring.common.validation.annotation.ValidSize; +import PerfumeOnMe.spring.fragrance.validation.annotation.ValidKeyword; import lombok.Getter; import lombok.Setter; diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java b/src/main/java/PerfumeOnMe/spring/fragrance/web/dto/FragranceResponseDTO.java similarity index 98% rename from src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java rename to src/main/java/PerfumeOnMe/spring/fragrance/web/dto/FragranceResponseDTO.java index 428a4d2..97af940 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/fragrance/FragranceResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/fragrance/web/dto/FragranceResponseDTO.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.dto.fragrance; +package PerfumeOnMe.spring.fragrance.web.dto; import java.util.List; diff --git a/src/main/java/PerfumeOnMe/spring/converter/ImageKeywordConverter.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/converter/ImageKeywordConverter.java similarity index 88% rename from src/main/java/PerfumeOnMe/spring/converter/ImageKeywordConverter.java rename to src/main/java/PerfumeOnMe/spring/imagekeyword/converter/ImageKeywordConverter.java index 9ef5cd8..2293229 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/ImageKeywordConverter.java +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/converter/ImageKeywordConverter.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.converter; +package PerfumeOnMe.spring.imagekeyword.converter; import java.util.List; import java.util.stream.Stream; @@ -8,11 +8,11 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.domain.ImageKeyword; -import PerfumeOnMe.spring.domain.ImageKeywordDescription; -import PerfumeOnMe.spring.domain.enums.KeywordCategory; -import PerfumeOnMe.spring.repository.imagekeyworddescription.ImageKeywordDescriptionRepository; -import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordResponseDTO; +import PerfumeOnMe.spring.common.enums.KeywordCategory; +import PerfumeOnMe.spring.imagekeyword.domain.ImageKeyword; +import PerfumeOnMe.spring.imagekeyword.domain.ImageKeywordDescription; +import PerfumeOnMe.spring.imagekeyword.repository.imagekeyworddescription.ImageKeywordDescriptionRepository; +import PerfumeOnMe.spring.imagekeyword.web.dto.ImageKeywordResponseDTO; public class ImageKeywordConverter { diff --git a/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/domain/ImageKeyword.java similarity index 82% rename from src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java rename to src/main/java/PerfumeOnMe/spring/imagekeyword/domain/ImageKeyword.java index f3ade44..133d178 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/ImageKeyword.java +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/domain/ImageKeyword.java @@ -1,14 +1,15 @@ -package PerfumeOnMe.spring.domain; +package PerfumeOnMe.spring.imagekeyword.domain; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import PerfumeOnMe.spring.domain.base.BaseEntity; -import PerfumeOnMe.spring.domain.enums.Ambience; -import PerfumeOnMe.spring.domain.enums.Gender; -import PerfumeOnMe.spring.domain.enums.Personality; -import PerfumeOnMe.spring.domain.enums.Season; -import PerfumeOnMe.spring.domain.enums.Style; +import PerfumeOnMe.spring.common.base.BaseEntity; +import PerfumeOnMe.spring.common.enums.Ambience; +import PerfumeOnMe.spring.common.enums.Gender; +import PerfumeOnMe.spring.common.enums.Personality; +import PerfumeOnMe.spring.common.enums.Season; +import PerfumeOnMe.spring.common.enums.Style; +import PerfumeOnMe.spring.user.domain.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; diff --git a/src/main/java/PerfumeOnMe/spring/domain/ImageKeywordDescription.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/domain/ImageKeywordDescription.java similarity index 87% rename from src/main/java/PerfumeOnMe/spring/domain/ImageKeywordDescription.java rename to src/main/java/PerfumeOnMe/spring/imagekeyword/domain/ImageKeywordDescription.java index 9d11706..570754d 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/ImageKeywordDescription.java +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/domain/ImageKeywordDescription.java @@ -1,10 +1,10 @@ -package PerfumeOnMe.spring.domain; +package PerfumeOnMe.spring.imagekeyword.domain; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import PerfumeOnMe.spring.domain.base.BaseEntity; -import PerfumeOnMe.spring.domain.enums.KeywordCategory; +import PerfumeOnMe.spring.common.base.BaseEntity; +import PerfumeOnMe.spring.common.enums.KeywordCategory; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; diff --git a/src/main/java/PerfumeOnMe/spring/service/redis/ImageKeywordRedisService.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/redis/ImageKeywordRedisService.java similarity index 93% rename from src/main/java/PerfumeOnMe/spring/service/redis/ImageKeywordRedisService.java rename to src/main/java/PerfumeOnMe/spring/imagekeyword/redis/ImageKeywordRedisService.java index 42c97d2..f639722 100644 --- a/src/main/java/PerfumeOnMe/spring/service/redis/ImageKeywordRedisService.java +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/redis/ImageKeywordRedisService.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service.redis; +package PerfumeOnMe.spring.imagekeyword.redis; import java.time.Duration; @@ -9,7 +9,7 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordResponseDTO; +import PerfumeOnMe.spring.imagekeyword.web.dto.ImageKeywordResponseDTO; import lombok.RequiredArgsConstructor; @Service diff --git a/src/main/java/PerfumeOnMe/spring/repository/imagekeyword/ImageKeywordRepository.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/repository/ImageKeywordRepository.java similarity index 77% rename from src/main/java/PerfumeOnMe/spring/repository/imagekeyword/ImageKeywordRepository.java rename to src/main/java/PerfumeOnMe/spring/imagekeyword/repository/ImageKeywordRepository.java index 7726f7d..4da2cbb 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/imagekeyword/ImageKeywordRepository.java +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/repository/ImageKeywordRepository.java @@ -1,12 +1,12 @@ -package PerfumeOnMe.spring.repository.imagekeyword; +package PerfumeOnMe.spring.imagekeyword.repository; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -import PerfumeOnMe.spring.domain.ImageKeyword; -import PerfumeOnMe.spring.domain.User; +import PerfumeOnMe.spring.imagekeyword.domain.ImageKeyword; +import PerfumeOnMe.spring.user.domain.User; public interface ImageKeywordRepository extends JpaRepository { List findAllByUserOrderByCreatedAtDesc(User user); diff --git a/src/main/java/PerfumeOnMe/spring/repository/imagekeyworddescription/ImageKeywordDescriptionRepository.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/repository/imagekeyworddescription/ImageKeywordDescriptionRepository.java similarity index 60% rename from src/main/java/PerfumeOnMe/spring/repository/imagekeyworddescription/ImageKeywordDescriptionRepository.java rename to src/main/java/PerfumeOnMe/spring/imagekeyword/repository/imagekeyworddescription/ImageKeywordDescriptionRepository.java index 8be1fa6..43a2286 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/imagekeyworddescription/ImageKeywordDescriptionRepository.java +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/repository/imagekeyworddescription/ImageKeywordDescriptionRepository.java @@ -1,11 +1,11 @@ -package PerfumeOnMe.spring.repository.imagekeyworddescription; +package PerfumeOnMe.spring.imagekeyword.repository.imagekeyworddescription; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -import PerfumeOnMe.spring.domain.ImageKeywordDescription; -import PerfumeOnMe.spring.domain.enums.KeywordCategory; +import PerfumeOnMe.spring.common.enums.KeywordCategory; +import PerfumeOnMe.spring.imagekeyword.domain.ImageKeywordDescription; public interface ImageKeywordDescriptionRepository extends JpaRepository { Optional findByKeywordAndCategory(String keyword, KeywordCategory category); diff --git a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordDescriptionService.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/service/ImageKeywordDescriptionService.java similarity index 73% rename from src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordDescriptionService.java rename to src/main/java/PerfumeOnMe/spring/imagekeyword/service/ImageKeywordDescriptionService.java index 541873e..20af83f 100644 --- a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordDescriptionService.java +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/service/ImageKeywordDescriptionService.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service.imagekeyword; +package PerfumeOnMe.spring.imagekeyword.service; import java.util.List; import java.util.stream.Stream; @@ -6,14 +6,14 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import PerfumeOnMe.spring.domain.ImageKeywordDescription; -import PerfumeOnMe.spring.domain.enums.Ambience; -import PerfumeOnMe.spring.domain.enums.Gender; -import PerfumeOnMe.spring.domain.enums.KeywordCategory; -import PerfumeOnMe.spring.domain.enums.Personality; -import PerfumeOnMe.spring.domain.enums.Season; -import PerfumeOnMe.spring.domain.enums.Style; -import PerfumeOnMe.spring.repository.imagekeyworddescription.ImageKeywordDescriptionRepository; +import PerfumeOnMe.spring.common.enums.Ambience; +import PerfumeOnMe.spring.common.enums.Gender; +import PerfumeOnMe.spring.common.enums.KeywordCategory; +import PerfumeOnMe.spring.common.enums.Personality; +import PerfumeOnMe.spring.common.enums.Season; +import PerfumeOnMe.spring.common.enums.Style; +import PerfumeOnMe.spring.imagekeyword.domain.ImageKeywordDescription; +import PerfumeOnMe.spring.imagekeyword.repository.imagekeyworddescription.ImageKeywordDescriptionRepository; import lombok.RequiredArgsConstructor; @Service diff --git a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordPreviewService.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/service/ImageKeywordPreviewService.java similarity index 82% rename from src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordPreviewService.java rename to src/main/java/PerfumeOnMe/spring/imagekeyword/service/ImageKeywordPreviewService.java index f26705d..f2c6d4b 100644 --- a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordPreviewService.java +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/service/ImageKeywordPreviewService.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service.imagekeyword; +package PerfumeOnMe.spring.imagekeyword.service; import java.util.List; import java.util.stream.Collectors; @@ -6,14 +6,14 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import PerfumeOnMe.spring.service.external.FastApiClient; -import PerfumeOnMe.spring.service.redis.ImageKeywordRedisService; -import PerfumeOnMe.spring.util.CharacterImageMapper; -import PerfumeOnMe.spring.web.dto.external.FastApiRecommendRequest; -import PerfumeOnMe.spring.web.dto.external.FastApiRecommendResponse; -import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordRequestDTO.ImageKeywordPreviewRequestDTO; -import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordResponseDTO.ImageKeywordPreviewResponseDTO; -import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordResponseDTO.ImageKeywordPreviewResponseDTO.FragranceRecommendation; +import PerfumeOnMe.spring.common.util.CharacterImageMapper; +import PerfumeOnMe.spring.external.fastapi.FastApiClient; +import PerfumeOnMe.spring.external.fastapi.dto.FastApiRecommendRequest; +import PerfumeOnMe.spring.external.fastapi.dto.FastApiRecommendResponse; +import PerfumeOnMe.spring.imagekeyword.redis.ImageKeywordRedisService; +import PerfumeOnMe.spring.imagekeyword.web.dto.ImageKeywordRequestDTO.ImageKeywordPreviewRequestDTO; +import PerfumeOnMe.spring.imagekeyword.web.dto.ImageKeywordResponseDTO.ImageKeywordPreviewResponseDTO; +import PerfumeOnMe.spring.imagekeyword.web.dto.ImageKeywordResponseDTO.ImageKeywordPreviewResponseDTO.FragranceRecommendation; import lombok.RequiredArgsConstructor; @Service diff --git a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordService.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/service/ImageKeywordService.java similarity index 77% rename from src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordService.java rename to src/main/java/PerfumeOnMe/spring/imagekeyword/service/ImageKeywordService.java index 38f2aa4..11dbcfd 100644 --- a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordService.java +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/service/ImageKeywordService.java @@ -1,8 +1,8 @@ -package PerfumeOnMe.spring.service.imagekeyword; +package PerfumeOnMe.spring.imagekeyword.service; import java.util.List; -import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordResponseDTO; +import PerfumeOnMe.spring.imagekeyword.web.dto.ImageKeywordResponseDTO; public interface ImageKeywordService { List getImageKeywordList(Long userId); diff --git a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/service/ImageKeywordServiceImpl.java similarity index 83% rename from src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java rename to src/main/java/PerfumeOnMe/spring/imagekeyword/service/ImageKeywordServiceImpl.java index 52ad5b9..27834f8 100644 --- a/src/main/java/PerfumeOnMe/spring/service/imagekeyword/ImageKeywordServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/service/ImageKeywordServiceImpl.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service.imagekeyword; +package PerfumeOnMe.spring.imagekeyword.service; import java.util.List; @@ -7,16 +7,16 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.converter.ImageKeywordConverter; -import PerfumeOnMe.spring.domain.ImageKeyword; -import PerfumeOnMe.spring.domain.User; -import PerfumeOnMe.spring.repository.imagekeyword.ImageKeywordRepository; -import PerfumeOnMe.spring.repository.imagekeyworddescription.ImageKeywordDescriptionRepository; -import PerfumeOnMe.spring.repository.user.UserRepository; -import PerfumeOnMe.spring.service.redis.ImageKeywordRedisService; -import PerfumeOnMe.spring.util.EnumDisplayNameMapper; -import PerfumeOnMe.spring.util.JsonUtils; -import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordResponseDTO; +import PerfumeOnMe.spring.common.util.EnumDisplayNameMapper; +import PerfumeOnMe.spring.common.util.JsonUtils; +import PerfumeOnMe.spring.imagekeyword.converter.ImageKeywordConverter; +import PerfumeOnMe.spring.imagekeyword.domain.ImageKeyword; +import PerfumeOnMe.spring.imagekeyword.redis.ImageKeywordRedisService; +import PerfumeOnMe.spring.imagekeyword.repository.ImageKeywordRepository; +import PerfumeOnMe.spring.imagekeyword.repository.imagekeyworddescription.ImageKeywordDescriptionRepository; +import PerfumeOnMe.spring.imagekeyword.web.dto.ImageKeywordResponseDTO; +import PerfumeOnMe.spring.user.domain.User; +import PerfumeOnMe.spring.user.repository.UserRepository; import lombok.RequiredArgsConstructor; @Service @@ -50,11 +50,11 @@ public ImageKeywordResponseDTO.ImageKeywordDetailResponseDTO getImageKeywordDeta if (!imageKeywordRepository.existsById(imageKeywordId)) { throw new GeneralException(ErrorStatus.IMAGEKEYWORD_ID_NULL); } - + // 사용자의 이미지 키워드 결과 여부 검증 ImageKeyword keyword = imageKeywordRepository.findByIdAndUser(imageKeywordId, user) .orElseThrow(() -> new GeneralException(ErrorStatus.INVALID_IMAGEKEYWORD_ID)); - + return ImageKeywordConverter.toImageKeywordDetailResponse(keyword, imageKeywordDescriptionRepository); } diff --git a/src/main/java/PerfumeOnMe/spring/util/ImageKeywordDescriptionUtils.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/util/ImageKeywordDescriptionUtils.java similarity index 81% rename from src/main/java/PerfumeOnMe/spring/util/ImageKeywordDescriptionUtils.java rename to src/main/java/PerfumeOnMe/spring/imagekeyword/util/ImageKeywordDescriptionUtils.java index 25501df..0a970be 100644 --- a/src/main/java/PerfumeOnMe/spring/util/ImageKeywordDescriptionUtils.java +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/util/ImageKeywordDescriptionUtils.java @@ -1,17 +1,17 @@ -package PerfumeOnMe.spring.util; +package PerfumeOnMe.spring.imagekeyword.util; import java.util.List; import java.util.stream.Stream; -import PerfumeOnMe.spring.domain.ImageKeyword; -import PerfumeOnMe.spring.domain.ImageKeywordDescription; -import PerfumeOnMe.spring.domain.enums.Ambience; -import PerfumeOnMe.spring.domain.enums.Gender; -import PerfumeOnMe.spring.domain.enums.KeywordCategory; -import PerfumeOnMe.spring.domain.enums.Personality; -import PerfumeOnMe.spring.domain.enums.Season; -import PerfumeOnMe.spring.domain.enums.Style; -import PerfumeOnMe.spring.repository.imagekeyworddescription.ImageKeywordDescriptionRepository; +import PerfumeOnMe.spring.common.enums.Ambience; +import PerfumeOnMe.spring.common.enums.Gender; +import PerfumeOnMe.spring.common.enums.KeywordCategory; +import PerfumeOnMe.spring.common.enums.Personality; +import PerfumeOnMe.spring.common.enums.Season; +import PerfumeOnMe.spring.common.enums.Style; +import PerfumeOnMe.spring.imagekeyword.domain.ImageKeyword; +import PerfumeOnMe.spring.imagekeyword.domain.ImageKeywordDescription; +import PerfumeOnMe.spring.imagekeyword.repository.imagekeyworddescription.ImageKeywordDescriptionRepository; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -31,9 +31,9 @@ public class ImageKeywordDescriptionUtils { * @param descriptionRepository 설명 Repository * @return 조합된 설명 문자열 */ - public static String getDescriptions(String ambience, String style, String gender, String season, + public static String getDescriptions(String ambience, String style, String gender, String season, String personality, ImageKeywordDescriptionRepository descriptionRepository) { - + List descriptions = Stream.of( new EnumWithCategory(Ambience.fromDisplayName(ambience).name(), KeywordCategory.AMBIENCE), new EnumWithCategory(Style.fromDisplayName(style).name(), KeywordCategory.STYLE), @@ -57,9 +57,9 @@ public static String getDescriptions(String ambience, String style, String gende * @param descriptionRepository 설명 Repository * @return 조합된 설명 문자열 */ - public static String getDescriptionsFromEntity(ImageKeyword keyword, + public static String getDescriptionsFromEntity(ImageKeyword keyword, ImageKeywordDescriptionRepository descriptionRepository) { - + List descriptions = Stream.of( new EnumWithCategory(keyword.getAmbience().name(), KeywordCategory.AMBIENCE), new EnumWithCategory(keyword.getStyle().name(), KeywordCategory.STYLE), diff --git a/src/main/java/PerfumeOnMe/spring/util/ImageKeywordValidationUtils.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/util/ImageKeywordValidationUtils.java similarity index 84% rename from src/main/java/PerfumeOnMe/spring/util/ImageKeywordValidationUtils.java rename to src/main/java/PerfumeOnMe/spring/imagekeyword/util/ImageKeywordValidationUtils.java index 56d1201..878c5ac 100644 --- a/src/main/java/PerfumeOnMe/spring/util/ImageKeywordValidationUtils.java +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/util/ImageKeywordValidationUtils.java @@ -1,9 +1,9 @@ -package PerfumeOnMe.spring.util; +package PerfumeOnMe.spring.imagekeyword.util; import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.domain.User; -import PerfumeOnMe.spring.repository.user.UserRepository; +import PerfumeOnMe.spring.user.domain.User; +import PerfumeOnMe.spring.user.repository.UserRepository; import lombok.AccessLevel; import lombok.NoArgsConstructor; diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidEnumKeyword.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/validation/annotation/ValidEnumKeyword.java similarity index 81% rename from src/main/java/PerfumeOnMe/spring/validation/annotation/ValidEnumKeyword.java rename to src/main/java/PerfumeOnMe/spring/imagekeyword/validation/annotation/ValidEnumKeyword.java index 1202bbf..21d5062 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidEnumKeyword.java +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/validation/annotation/ValidEnumKeyword.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.validation.annotation; +package PerfumeOnMe.spring.imagekeyword.validation.annotation; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; @@ -6,7 +6,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import PerfumeOnMe.spring.validation.validator.ValidEnumKeywordValidator; +import PerfumeOnMe.spring.imagekeyword.validation.validator.ValidEnumKeywordValidator; import jakarta.validation.Constraint; import jakarta.validation.Payload; diff --git a/src/main/java/PerfumeOnMe/spring/validation/validator/ValidEnumKeywordValidator.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/validation/validator/ValidEnumKeywordValidator.java similarity index 86% rename from src/main/java/PerfumeOnMe/spring/validation/validator/ValidEnumKeywordValidator.java rename to src/main/java/PerfumeOnMe/spring/imagekeyword/validation/validator/ValidEnumKeywordValidator.java index e9f18f5..130eb37 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/validator/ValidEnumKeywordValidator.java +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/validation/validator/ValidEnumKeywordValidator.java @@ -1,9 +1,9 @@ -package PerfumeOnMe.spring.validation.validator; +package PerfumeOnMe.spring.imagekeyword.validation.validator; import java.lang.reflect.Method; import java.util.Arrays; -import PerfumeOnMe.spring.validation.annotation.ValidEnumKeyword; +import PerfumeOnMe.spring.imagekeyword.validation.annotation.ValidEnumKeyword; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/web/controller/ImageKeywordController.java similarity index 87% rename from src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java rename to src/main/java/PerfumeOnMe/spring/imagekeyword/web/controller/ImageKeywordController.java index ccb3de3..5e713b3 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/ImageKeywordController.java +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/web/controller/ImageKeywordController.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.controller; +package PerfumeOnMe.spring.imagekeyword.web.controller; import java.util.List; @@ -13,12 +13,12 @@ import org.springframework.web.bind.annotation.RestController; import PerfumeOnMe.spring.apiPayload.ApiResponse; -import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; -import PerfumeOnMe.spring.service.imagekeyword.ImageKeywordPreviewService; -import PerfumeOnMe.spring.service.imagekeyword.ImageKeywordService; -import PerfumeOnMe.spring.web.docs.imagekeyword.ImageKeywordControllerDocs; -import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordRequestDTO; -import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordResponseDTO; +import PerfumeOnMe.spring.imagekeyword.service.ImageKeywordPreviewService; +import PerfumeOnMe.spring.imagekeyword.service.ImageKeywordService; +import PerfumeOnMe.spring.imagekeyword.web.docs.ImageKeywordControllerDocs; +import PerfumeOnMe.spring.imagekeyword.web.dto.ImageKeywordRequestDTO; +import PerfumeOnMe.spring.imagekeyword.web.dto.ImageKeywordResponseDTO; +import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; import lombok.RequiredArgsConstructor; @RestController diff --git a/src/main/java/PerfumeOnMe/spring/web/docs/imagekeyword/ImageKeywordControllerDocs.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/web/docs/ImageKeywordControllerDocs.java similarity index 97% rename from src/main/java/PerfumeOnMe/spring/web/docs/imagekeyword/ImageKeywordControllerDocs.java rename to src/main/java/PerfumeOnMe/spring/imagekeyword/web/docs/ImageKeywordControllerDocs.java index 65b10ae..a1eade6 100644 --- a/src/main/java/PerfumeOnMe/spring/web/docs/imagekeyword/ImageKeywordControllerDocs.java +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/web/docs/ImageKeywordControllerDocs.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.docs.imagekeyword; +package PerfumeOnMe.spring.imagekeyword.web.docs; import java.util.List; @@ -9,9 +9,9 @@ import org.springframework.web.bind.annotation.RequestBody; import PerfumeOnMe.spring.apiPayload.ApiResponse; -import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; -import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordRequestDTO; -import PerfumeOnMe.spring.web.dto.imagekeyword.ImageKeywordResponseDTO; +import PerfumeOnMe.spring.imagekeyword.web.dto.ImageKeywordRequestDTO; +import PerfumeOnMe.spring.imagekeyword.web.dto.ImageKeywordResponseDTO; +import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordRequestDTO.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/web/dto/ImageKeywordRequestDTO.java similarity index 80% rename from src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordRequestDTO.java rename to src/main/java/PerfumeOnMe/spring/imagekeyword/web/dto/ImageKeywordRequestDTO.java index 3ba222c..1860b29 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/web/dto/ImageKeywordRequestDTO.java @@ -1,11 +1,11 @@ -package PerfumeOnMe.spring.web.dto.imagekeyword; - -import PerfumeOnMe.spring.domain.enums.Ambience; -import PerfumeOnMe.spring.domain.enums.Gender; -import PerfumeOnMe.spring.domain.enums.Personality; -import PerfumeOnMe.spring.domain.enums.Season; -import PerfumeOnMe.spring.domain.enums.Style; -import PerfumeOnMe.spring.validation.annotation.ValidEnumKeyword; +package PerfumeOnMe.spring.imagekeyword.web.dto; + +import PerfumeOnMe.spring.common.enums.Ambience; +import PerfumeOnMe.spring.common.enums.Gender; +import PerfumeOnMe.spring.common.enums.Personality; +import PerfumeOnMe.spring.common.enums.Season; +import PerfumeOnMe.spring.common.enums.Style; +import PerfumeOnMe.spring.imagekeyword.validation.annotation.ValidEnumKeyword; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import lombok.Builder; diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordResponseDTO.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/web/dto/ImageKeywordResponseDTO.java similarity index 98% rename from src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordResponseDTO.java rename to src/main/java/PerfumeOnMe/spring/imagekeyword/web/dto/ImageKeywordResponseDTO.java index 77909d8..4989fc2 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/imagekeyword/ImageKeywordResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/web/dto/ImageKeywordResponseDTO.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.dto.imagekeyword; +package PerfumeOnMe.spring.imagekeyword.web.dto; import java.time.LocalDateTime; import java.util.List; diff --git a/src/main/java/PerfumeOnMe/spring/converter/PbtiConverter.java b/src/main/java/PerfumeOnMe/spring/pbti/converter/PbtiConverter.java similarity index 94% rename from src/main/java/PerfumeOnMe/spring/converter/PbtiConverter.java rename to src/main/java/PerfumeOnMe/spring/pbti/converter/PbtiConverter.java index ec27e52..a1de7bd 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/PbtiConverter.java +++ b/src/main/java/PerfumeOnMe/spring/pbti/converter/PbtiConverter.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.converter; +package PerfumeOnMe.spring.pbti.converter; import java.time.LocalDateTime; import java.util.List; @@ -9,8 +9,8 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.domain.PBTI; -import PerfumeOnMe.spring.web.dto.Pbti.PbtiResponseDTO; +import PerfumeOnMe.spring.pbti.domain.PBTI; +import PerfumeOnMe.spring.pbti.web.dto.PbtiResponseDTO; public class PbtiConverter { diff --git a/src/main/java/PerfumeOnMe/spring/domain/PBTI.java b/src/main/java/PerfumeOnMe/spring/pbti/domain/PBTI.java similarity index 92% rename from src/main/java/PerfumeOnMe/spring/domain/PBTI.java rename to src/main/java/PerfumeOnMe/spring/pbti/domain/PBTI.java index 01461d5..eb3ebad 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/PBTI.java +++ b/src/main/java/PerfumeOnMe/spring/pbti/domain/PBTI.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.domain; +package PerfumeOnMe.spring.pbti.domain; import java.util.ArrayList; import java.util.List; @@ -6,8 +6,9 @@ import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import PerfumeOnMe.spring.domain.base.BaseEntity; -import PerfumeOnMe.spring.domain.mapping.RecommendedFragrance; +import PerfumeOnMe.spring.common.base.BaseEntity; +import PerfumeOnMe.spring.fragrance.domain.mapping.RecommendedFragrance; +import PerfumeOnMe.spring.user.domain.User; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; diff --git a/src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepository.java b/src/main/java/PerfumeOnMe/spring/pbti/repository/PbtiRepository.java similarity index 76% rename from src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepository.java rename to src/main/java/PerfumeOnMe/spring/pbti/repository/PbtiRepository.java index 3202ec3..25abb30 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepository.java +++ b/src/main/java/PerfumeOnMe/spring/pbti/repository/PbtiRepository.java @@ -1,11 +1,11 @@ -package PerfumeOnMe.spring.repository.pbti; +package PerfumeOnMe.spring.pbti.repository; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -import PerfumeOnMe.spring.domain.PBTI; +import PerfumeOnMe.spring.pbti.domain.PBTI; public interface PbtiRepository extends JpaRepository, PbtiRepositoryCustom { diff --git a/src/main/java/PerfumeOnMe/spring/pbti/repository/PbtiRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/pbti/repository/PbtiRepositoryCustom.java new file mode 100644 index 0000000..cc881cc --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/pbti/repository/PbtiRepositoryCustom.java @@ -0,0 +1,4 @@ +package PerfumeOnMe.spring.pbti.repository; + +public interface PbtiRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/pbti/repository/PbtiRepositoryImpl.java similarity index 81% rename from src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepositoryImpl.java rename to src/main/java/PerfumeOnMe/spring/pbti/repository/PbtiRepositoryImpl.java index 119acb2..3d3f6e2 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepositoryImpl.java +++ b/src/main/java/PerfumeOnMe/spring/pbti/repository/PbtiRepositoryImpl.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.repository.pbti; +package PerfumeOnMe.spring.pbti.repository; import org.springframework.stereotype.Repository; diff --git a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiService.java b/src/main/java/PerfumeOnMe/spring/pbti/service/PbtiService.java similarity index 84% rename from src/main/java/PerfumeOnMe/spring/service/pbti/PbtiService.java rename to src/main/java/PerfumeOnMe/spring/pbti/service/PbtiService.java index c3ecd2c..5a34d44 100644 --- a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiService.java +++ b/src/main/java/PerfumeOnMe/spring/pbti/service/PbtiService.java @@ -1,7 +1,7 @@ -package PerfumeOnMe.spring.service.pbti; +package PerfumeOnMe.spring.pbti.service; -import PerfumeOnMe.spring.web.dto.Pbti.PbtiRequestDTO; -import PerfumeOnMe.spring.web.dto.Pbti.PbtiResponseDTO; +import PerfumeOnMe.spring.pbti.web.dto.PbtiRequestDTO; +import PerfumeOnMe.spring.pbti.web.dto.PbtiResponseDTO; public interface PbtiService { diff --git a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java b/src/main/java/PerfumeOnMe/spring/pbti/service/PbtiServiceImpl.java similarity index 91% rename from src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java rename to src/main/java/PerfumeOnMe/spring/pbti/service/PbtiServiceImpl.java index 5a90593..f26ed39 100644 --- a/src/main/java/PerfumeOnMe/spring/service/pbti/PbtiServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/pbti/service/PbtiServiceImpl.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service.pbti; +package PerfumeOnMe.spring.pbti.service; import java.time.Duration; import java.util.List; @@ -13,17 +13,17 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.converter.PbtiConverter; -import PerfumeOnMe.spring.domain.PBTI; -import PerfumeOnMe.spring.domain.User; -import PerfumeOnMe.spring.repository.pbti.PbtiRepository; -import PerfumeOnMe.spring.repository.user.UserRepository; -import PerfumeOnMe.spring.service.external.FastApiClient; -import PerfumeOnMe.spring.util.JsonUtils; -import PerfumeOnMe.spring.web.dto.Pbti.PbtiRequestDTO; -import PerfumeOnMe.spring.web.dto.Pbti.PbtiResponseDTO; -import PerfumeOnMe.spring.web.dto.external.FastApiPbtiRecommendResponse; -import PerfumeOnMe.spring.web.dto.external.FastApiRecommendRequest; +import PerfumeOnMe.spring.common.util.JsonUtils; +import PerfumeOnMe.spring.external.fastapi.FastApiClient; +import PerfumeOnMe.spring.external.fastapi.dto.FastApiPbtiRecommendResponse; +import PerfumeOnMe.spring.external.fastapi.dto.FastApiRecommendRequest; +import PerfumeOnMe.spring.pbti.converter.PbtiConverter; +import PerfumeOnMe.spring.pbti.domain.PBTI; +import PerfumeOnMe.spring.pbti.repository.PbtiRepository; +import PerfumeOnMe.spring.pbti.web.dto.PbtiRequestDTO; +import PerfumeOnMe.spring.pbti.web.dto.PbtiResponseDTO; +import PerfumeOnMe.spring.user.domain.User; +import PerfumeOnMe.spring.user.repository.UserRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/PbtiController.java b/src/main/java/PerfumeOnMe/spring/pbti/web/controller/PbtiController.java similarity index 96% rename from src/main/java/PerfumeOnMe/spring/web/controller/PbtiController.java rename to src/main/java/PerfumeOnMe/spring/pbti/web/controller/PbtiController.java index 3c3f7ea..bf0bed9 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/PbtiController.java +++ b/src/main/java/PerfumeOnMe/spring/pbti/web/controller/PbtiController.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.controller; +package PerfumeOnMe.spring.pbti.web.controller; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -12,10 +12,10 @@ import org.springframework.web.bind.annotation.RestController; import PerfumeOnMe.spring.apiPayload.ApiResponse; -import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; -import PerfumeOnMe.spring.service.pbti.PbtiService; -import PerfumeOnMe.spring.web.dto.Pbti.PbtiRequestDTO; -import PerfumeOnMe.spring.web.dto.Pbti.PbtiResponseDTO; +import PerfumeOnMe.spring.pbti.service.PbtiService; +import PerfumeOnMe.spring.pbti.web.dto.PbtiRequestDTO; +import PerfumeOnMe.spring.pbti.web.dto.PbtiResponseDTO; +import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/ChatGptRequest.java b/src/main/java/PerfumeOnMe/spring/pbti/web/dto/ChatGptRequest.java similarity index 90% rename from src/main/java/PerfumeOnMe/spring/web/dto/Pbti/ChatGptRequest.java rename to src/main/java/PerfumeOnMe/spring/pbti/web/dto/ChatGptRequest.java index ed35a3e..7916a0d 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/ChatGptRequest.java +++ b/src/main/java/PerfumeOnMe/spring/pbti/web/dto/ChatGptRequest.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.dto.Pbti; +package PerfumeOnMe.spring.pbti.web.dto; import java.util.List; diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/ChatGptResponse.java b/src/main/java/PerfumeOnMe/spring/pbti/web/dto/ChatGptResponse.java similarity index 87% rename from src/main/java/PerfumeOnMe/spring/web/dto/Pbti/ChatGptResponse.java rename to src/main/java/PerfumeOnMe/spring/pbti/web/dto/ChatGptResponse.java index d16c812..0006d23 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/ChatGptResponse.java +++ b/src/main/java/PerfumeOnMe/spring/pbti/web/dto/ChatGptResponse.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.dto.Pbti; +package PerfumeOnMe.spring.pbti.web.dto; import java.util.List; diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiRequestDTO.java b/src/main/java/PerfumeOnMe/spring/pbti/web/dto/PbtiRequestDTO.java similarity index 96% rename from src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiRequestDTO.java rename to src/main/java/PerfumeOnMe/spring/pbti/web/dto/PbtiRequestDTO.java index 1042a71..2b34d1b 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/pbti/web/dto/PbtiRequestDTO.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.dto.Pbti; +package PerfumeOnMe.spring.pbti.web.dto; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java b/src/main/java/PerfumeOnMe/spring/pbti/web/dto/PbtiResponseDTO.java similarity index 99% rename from src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java rename to src/main/java/PerfumeOnMe/spring/pbti/web/dto/PbtiResponseDTO.java index 1d22129..70f5742 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/Pbti/PbtiResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/pbti/web/dto/PbtiResponseDTO.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.dto.Pbti; +package PerfumeOnMe.spring.pbti.web.dto; import java.time.LocalDateTime; import java.util.List; diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceBaseNote/FragranceBaseNoteRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/fragranceBaseNote/FragranceBaseNoteRepositoryCustom.java deleted file mode 100644 index 8106888..0000000 --- a/src/main/java/PerfumeOnMe/spring/repository/fragranceBaseNote/FragranceBaseNoteRepositoryCustom.java +++ /dev/null @@ -1,4 +0,0 @@ -package PerfumeOnMe.spring.repository.fragranceBaseNote; - -public interface FragranceBaseNoteRepositoryCustom { -} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceLocation/FragranceLocationRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/fragranceLocation/FragranceLocationRepositoryCustom.java deleted file mode 100644 index fd2aaa3..0000000 --- a/src/main/java/PerfumeOnMe/spring/repository/fragranceLocation/FragranceLocationRepositoryCustom.java +++ /dev/null @@ -1,4 +0,0 @@ -package PerfumeOnMe.spring.repository.fragranceLocation; - -public interface FragranceLocationRepositoryCustom { -} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryCustom.java deleted file mode 100644 index 4041aba..0000000 --- a/src/main/java/PerfumeOnMe/spring/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryCustom.java +++ /dev/null @@ -1,4 +0,0 @@ -package PerfumeOnMe.spring.repository.fragranceMiddleNote; - -public interface FragranceMiddleNoteRepositoryCustom { -} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepositoryCustom.java deleted file mode 100644 index 4ff8943..0000000 --- a/src/main/java/PerfumeOnMe/spring/repository/fragrancePrice/FragrancePriceRepositoryCustom.java +++ /dev/null @@ -1,4 +0,0 @@ -package PerfumeOnMe.spring.repository.fragrancePrice; - -public interface FragrancePriceRepositoryCustom { -} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceSeason/FragranceSeasonRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/fragranceSeason/FragranceSeasonRepositoryCustom.java deleted file mode 100644 index 85de18e..0000000 --- a/src/main/java/PerfumeOnMe/spring/repository/fragranceSeason/FragranceSeasonRepositoryCustom.java +++ /dev/null @@ -1,4 +0,0 @@ -package PerfumeOnMe.spring.repository.fragranceSeason; - -public interface FragranceSeasonRepositoryCustom { -} diff --git a/src/main/java/PerfumeOnMe/spring/repository/fragranceTopNote/FragranceTopNoteRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/fragranceTopNote/FragranceTopNoteRepositoryCustom.java deleted file mode 100644 index 496e3a1..0000000 --- a/src/main/java/PerfumeOnMe/spring/repository/fragranceTopNote/FragranceTopNoteRepositoryCustom.java +++ /dev/null @@ -1,4 +0,0 @@ -package PerfumeOnMe.spring.repository.fragranceTopNote; - -public interface FragranceTopNoteRepositoryCustom { -} diff --git a/src/main/java/PerfumeOnMe/spring/repository/location/LocationRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/location/LocationRepositoryCustom.java deleted file mode 100644 index 001f427..0000000 --- a/src/main/java/PerfumeOnMe/spring/repository/location/LocationRepositoryCustom.java +++ /dev/null @@ -1,4 +0,0 @@ -package PerfumeOnMe.spring.repository.location; - -public interface LocationRepositoryCustom { -} diff --git a/src/main/java/PerfumeOnMe/spring/repository/note/NoteRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/note/NoteRepositoryCustom.java deleted file mode 100644 index a919ab3..0000000 --- a/src/main/java/PerfumeOnMe/spring/repository/note/NoteRepositoryCustom.java +++ /dev/null @@ -1,4 +0,0 @@ -package PerfumeOnMe.spring.repository.note; - -public interface NoteRepositoryCustom { -} diff --git a/src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepositoryCustom.java deleted file mode 100644 index c068553..0000000 --- a/src/main/java/PerfumeOnMe/spring/repository/pbti/PbtiRepositoryCustom.java +++ /dev/null @@ -1,4 +0,0 @@ -package PerfumeOnMe.spring.repository.pbti; - -public interface PbtiRepositoryCustom { -} diff --git a/src/main/java/PerfumeOnMe/spring/repository/price/PriceRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/price/PriceRepositoryCustom.java deleted file mode 100644 index ccfd2bb..0000000 --- a/src/main/java/PerfumeOnMe/spring/repository/price/PriceRepositoryCustom.java +++ /dev/null @@ -1,4 +0,0 @@ -package PerfumeOnMe.spring.repository.price; - -public interface PriceRepositoryCustom { -} diff --git a/src/main/java/PerfumeOnMe/spring/repository/season/SeasonRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/season/SeasonRepositoryCustom.java deleted file mode 100644 index 94cd132..0000000 --- a/src/main/java/PerfumeOnMe/spring/repository/season/SeasonRepositoryCustom.java +++ /dev/null @@ -1,4 +0,0 @@ -package PerfumeOnMe.spring.repository.season; - -public interface SeasonRepositoryCustom { -} diff --git a/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepositoryCustom.java deleted file mode 100644 index e47f1dc..0000000 --- a/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepositoryCustom.java +++ /dev/null @@ -1,5 +0,0 @@ -package PerfumeOnMe.spring.repository.userFragrance; - -public interface UserFragranceRepositoryCustom { - -} diff --git a/src/main/java/PerfumeOnMe/spring/aws/s3/AmazonS3Manager.java b/src/main/java/PerfumeOnMe/spring/s3file/aws/AmazonS3Manager.java similarity index 91% rename from src/main/java/PerfumeOnMe/spring/aws/s3/AmazonS3Manager.java rename to src/main/java/PerfumeOnMe/spring/s3file/aws/AmazonS3Manager.java index 848e730..d39ce16 100644 --- a/src/main/java/PerfumeOnMe/spring/aws/s3/AmazonS3Manager.java +++ b/src/main/java/PerfumeOnMe/spring/s3file/aws/AmazonS3Manager.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.aws.s3; +package PerfumeOnMe.spring.s3file.aws; import java.io.IOException; import java.net.URL; @@ -13,9 +13,9 @@ import com.amazonaws.services.s3.model.ObjectMetadata; import com.amazonaws.services.s3.model.PutObjectRequest; -import PerfumeOnMe.spring.config.AmazonConfig; -import PerfumeOnMe.spring.domain.Uuid; -import PerfumeOnMe.spring.repository.uuid.UuidRepository; +import PerfumeOnMe.spring.common.config.AmazonConfig; +import PerfumeOnMe.spring.uuid.domain.Uuid; +import PerfumeOnMe.spring.uuid.repository.UuidRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/PerfumeOnMe/spring/converter/S3Converter.java b/src/main/java/PerfumeOnMe/spring/s3file/converter/S3Converter.java similarity index 67% rename from src/main/java/PerfumeOnMe/spring/converter/S3Converter.java rename to src/main/java/PerfumeOnMe/spring/s3file/converter/S3Converter.java index e4469a1..b6c0edc 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/S3Converter.java +++ b/src/main/java/PerfumeOnMe/spring/s3file/converter/S3Converter.java @@ -1,7 +1,7 @@ -package PerfumeOnMe.spring.converter; +package PerfumeOnMe.spring.s3file.converter; -import PerfumeOnMe.spring.domain.Uuid; -import PerfumeOnMe.spring.web.dto.s3.s3ResponseDTO; +import PerfumeOnMe.spring.s3file.web.dto.s3ResponseDTO; +import PerfumeOnMe.spring.uuid.domain.Uuid; public class S3Converter { diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/S3Controller.java b/src/main/java/PerfumeOnMe/spring/s3file/web/controller/S3Controller.java similarity index 89% rename from src/main/java/PerfumeOnMe/spring/web/controller/S3Controller.java rename to src/main/java/PerfumeOnMe/spring/s3file/web/controller/S3Controller.java index 16fd6c2..922fc5e 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/S3Controller.java +++ b/src/main/java/PerfumeOnMe/spring/s3file/web/controller/S3Controller.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.controller; +package PerfumeOnMe.spring.s3file.web.controller; import java.net.URL; import java.util.List; @@ -13,12 +13,12 @@ import PerfumeOnMe.spring.apiPayload.ApiResponse; import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.aws.s3.AmazonS3Manager; -import PerfumeOnMe.spring.converter.S3Converter; -import PerfumeOnMe.spring.domain.Uuid; -import PerfumeOnMe.spring.repository.uuid.UuidRepository; -import PerfumeOnMe.spring.web.dto.s3.s3RequestDTO; -import PerfumeOnMe.spring.web.dto.s3.s3ResponseDTO; +import PerfumeOnMe.spring.s3file.aws.AmazonS3Manager; +import PerfumeOnMe.spring.s3file.converter.S3Converter; +import PerfumeOnMe.spring.s3file.web.dto.s3RequestDTO; +import PerfumeOnMe.spring.s3file.web.dto.s3ResponseDTO; +import PerfumeOnMe.spring.uuid.domain.Uuid; +import PerfumeOnMe.spring.uuid.repository.UuidRepository; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/s3/s3RequestDTO.java b/src/main/java/PerfumeOnMe/spring/s3file/web/dto/s3RequestDTO.java similarity index 78% rename from src/main/java/PerfumeOnMe/spring/web/dto/s3/s3RequestDTO.java rename to src/main/java/PerfumeOnMe/spring/s3file/web/dto/s3RequestDTO.java index 10cd783..5189811 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/s3/s3RequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/s3file/web/dto/s3RequestDTO.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.dto.s3; +package PerfumeOnMe.spring.s3file.web.dto; import lombok.Getter; diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/s3/s3ResponseDTO.java b/src/main/java/PerfumeOnMe/spring/s3file/web/dto/s3ResponseDTO.java similarity index 91% rename from src/main/java/PerfumeOnMe/spring/web/dto/s3/s3ResponseDTO.java rename to src/main/java/PerfumeOnMe/spring/s3file/web/dto/s3ResponseDTO.java index d2a5316..8914681 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/s3/s3ResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/s3file/web/dto/s3ResponseDTO.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.dto.s3; +package PerfumeOnMe.spring.s3file.web.dto; import lombok.Builder; import lombok.Getter; diff --git a/src/main/java/PerfumeOnMe/spring/config/security/AuthenticationManagerConfig.java b/src/main/java/PerfumeOnMe/spring/security/AuthenticationManagerConfig.java similarity index 81% rename from src/main/java/PerfumeOnMe/spring/config/security/AuthenticationManagerConfig.java rename to src/main/java/PerfumeOnMe/spring/security/AuthenticationManagerConfig.java index 4c54a6d..f503f89 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/AuthenticationManagerConfig.java +++ b/src/main/java/PerfumeOnMe/spring/security/AuthenticationManagerConfig.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config.security; +package PerfumeOnMe.spring.security; import java.util.List; @@ -8,8 +8,8 @@ import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.ProviderManager; -import PerfumeOnMe.spring.config.security.auth.provider.CustomLoginAuthenticationProvider; -import PerfumeOnMe.spring.config.security.auth.provider.JwtAuthenticationProvider; +import PerfumeOnMe.spring.security.auth.provider.CustomLoginAuthenticationProvider; +import PerfumeOnMe.spring.security.auth.provider.JwtAuthenticationProvider; import lombok.RequiredArgsConstructor; /* diff --git a/src/main/java/PerfumeOnMe/spring/config/security/PasswordEncoderConfig.java b/src/main/java/PerfumeOnMe/spring/security/PasswordEncoderConfig.java similarity index 92% rename from src/main/java/PerfumeOnMe/spring/config/security/PasswordEncoderConfig.java rename to src/main/java/PerfumeOnMe/spring/security/PasswordEncoderConfig.java index 14d51de..995d043 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/PasswordEncoderConfig.java +++ b/src/main/java/PerfumeOnMe/spring/security/PasswordEncoderConfig.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config.security; +package PerfumeOnMe.spring.security; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java b/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java similarity index 89% rename from src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java rename to src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java index 9aa9cb5..c89870e 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/SecurityConfig.java +++ b/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config.security; +package PerfumeOnMe.spring.security; import java.util.List; @@ -14,11 +14,11 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import PerfumeOnMe.spring.config.security.auth.filter.JwtAuthenticationFilter; -import PerfumeOnMe.spring.config.security.auth.filter.JwtExceptionHandlerFilter; -import PerfumeOnMe.spring.config.security.auth.filter.JwtLoginFilter; -import PerfumeOnMe.spring.config.security.auth.handler.JwtAccessDeniedHandler; -import PerfumeOnMe.spring.config.security.auth.handler.JwtAuthenticationEntryPoint; +import PerfumeOnMe.spring.security.auth.filter.JwtAuthenticationFilter; +import PerfumeOnMe.spring.security.auth.filter.JwtExceptionHandlerFilter; +import PerfumeOnMe.spring.security.auth.filter.JwtLoginFilter; +import PerfumeOnMe.spring.security.auth.handler.JwtAccessDeniedHandler; +import PerfumeOnMe.spring.security.auth.handler.JwtAuthenticationEntryPoint; import lombok.RequiredArgsConstructor; @Configuration diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/controller/LoginController.java b/src/main/java/PerfumeOnMe/spring/security/auth/controller/LoginController.java similarity index 83% rename from src/main/java/PerfumeOnMe/spring/config/security/auth/controller/LoginController.java rename to src/main/java/PerfumeOnMe/spring/security/auth/controller/LoginController.java index 5ba12b9..ca1b525 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/controller/LoginController.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/controller/LoginController.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config.security.auth.controller; +package PerfumeOnMe.spring.security.auth.controller; import java.io.IOException; @@ -9,10 +9,10 @@ import org.springframework.web.bind.annotation.RestController; import PerfumeOnMe.spring.apiPayload.ApiResponse; -import PerfumeOnMe.spring.config.security.auth.dto.AuthRequestDTO; -import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; -import PerfumeOnMe.spring.config.security.auth.service.LoginService; -import PerfumeOnMe.spring.web.dto.user.UserResponseDTO; +import PerfumeOnMe.spring.security.auth.dto.AuthRequestDTO; +import PerfumeOnMe.spring.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.security.auth.service.LoginService; +import PerfumeOnMe.spring.user.web.dto.UserResponseDTO; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/converter/AuthConverter.java b/src/main/java/PerfumeOnMe/spring/security/auth/converter/AuthConverter.java similarity index 61% rename from src/main/java/PerfumeOnMe/spring/config/security/auth/converter/AuthConverter.java rename to src/main/java/PerfumeOnMe/spring/security/auth/converter/AuthConverter.java index c610461..32a1332 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/converter/AuthConverter.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/converter/AuthConverter.java @@ -1,7 +1,7 @@ -package PerfumeOnMe.spring.config.security.auth.converter; +package PerfumeOnMe.spring.security.auth.converter; -import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; -import PerfumeOnMe.spring.domain.enums.Social; +import PerfumeOnMe.spring.common.enums.Social; +import PerfumeOnMe.spring.security.auth.dto.AuthResponseDTO; public class AuthConverter { diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthRequestDTO.java b/src/main/java/PerfumeOnMe/spring/security/auth/dto/AuthRequestDTO.java similarity index 83% rename from src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthRequestDTO.java rename to src/main/java/PerfumeOnMe/spring/security/auth/dto/AuthRequestDTO.java index 17c631e..d774997 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/dto/AuthRequestDTO.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config.security.auth.dto; +package PerfumeOnMe.spring.security.auth.dto; import jakarta.validation.constraints.NotNull; import lombok.Getter; diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthResponseDTO.java b/src/main/java/PerfumeOnMe/spring/security/auth/dto/AuthResponseDTO.java similarity index 76% rename from src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthResponseDTO.java rename to src/main/java/PerfumeOnMe/spring/security/auth/dto/AuthResponseDTO.java index 73236c3..3c30dd4 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/AuthResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/dto/AuthResponseDTO.java @@ -1,6 +1,6 @@ -package PerfumeOnMe.spring.config.security.auth.dto; +package PerfumeOnMe.spring.security.auth.dto; -import PerfumeOnMe.spring.domain.enums.Social; +import PerfumeOnMe.spring.common.enums.Social; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/JwtProperties.java b/src/main/java/PerfumeOnMe/spring/security/auth/dto/JwtProperties.java similarity index 90% rename from src/main/java/PerfumeOnMe/spring/config/security/auth/dto/JwtProperties.java rename to src/main/java/PerfumeOnMe/spring/security/auth/dto/JwtProperties.java index 6d39a22..fbe4740 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/dto/JwtProperties.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/dto/JwtProperties.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config.security.auth.dto; +package PerfumeOnMe.spring.security.auth.dto; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtAuthenticationFilter.java b/src/main/java/PerfumeOnMe/spring/security/auth/filter/JwtAuthenticationFilter.java similarity index 86% rename from src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtAuthenticationFilter.java rename to src/main/java/PerfumeOnMe/spring/security/auth/filter/JwtAuthenticationFilter.java index 2c61107..aaefb60 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtAuthenticationFilter.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/filter/JwtAuthenticationFilter.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config.security.auth.filter; +package PerfumeOnMe.spring.security.auth.filter; import java.io.IOException; @@ -11,10 +11,10 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.config.security.auth.manager.LogoutAccessTokenManager; -import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; -import PerfumeOnMe.spring.config.security.auth.token.JwtAuthenticationToken; -import PerfumeOnMe.spring.domain.enums.Social; +import PerfumeOnMe.spring.common.enums.Social; +import PerfumeOnMe.spring.security.auth.manager.LogoutAccessTokenManager; +import PerfumeOnMe.spring.security.auth.provider.JwtTokenProvider; +import PerfumeOnMe.spring.security.auth.token.JwtAuthenticationToken; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtExceptionHandlerFilter.java b/src/main/java/PerfumeOnMe/spring/security/auth/filter/JwtExceptionHandlerFilter.java similarity index 96% rename from src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtExceptionHandlerFilter.java rename to src/main/java/PerfumeOnMe/spring/security/auth/filter/JwtExceptionHandlerFilter.java index 9c62669..3c1211a 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtExceptionHandlerFilter.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/filter/JwtExceptionHandlerFilter.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config.security.auth.filter; +package PerfumeOnMe.spring.security.auth.filter; import static PerfumeOnMe.spring.apiPayload.ApiResponse.*; diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java b/src/main/java/PerfumeOnMe/spring/security/auth/filter/JwtLoginFilter.java similarity index 88% rename from src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java rename to src/main/java/PerfumeOnMe/spring/security/auth/filter/JwtLoginFilter.java index 9ed3233..b0435db 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/filter/JwtLoginFilter.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/filter/JwtLoginFilter.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config.security.auth.filter; +package PerfumeOnMe.spring.security.auth.filter; import static PerfumeOnMe.spring.apiPayload.ApiResponse.*; @@ -20,14 +20,14 @@ import PerfumeOnMe.spring.apiPayload.ApiResponse; import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.config.security.auth.converter.AuthConverter; -import PerfumeOnMe.spring.config.security.auth.dto.AuthRequestDTO; -import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; -import PerfumeOnMe.spring.config.security.auth.manager.LogoutAccessTokenManager; -import PerfumeOnMe.spring.config.security.auth.manager.RefreshTokenManager; -import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; -import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; -import PerfumeOnMe.spring.domain.enums.Social; +import PerfumeOnMe.spring.common.enums.Social; +import PerfumeOnMe.spring.security.auth.converter.AuthConverter; +import PerfumeOnMe.spring.security.auth.dto.AuthRequestDTO; +import PerfumeOnMe.spring.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.security.auth.manager.LogoutAccessTokenManager; +import PerfumeOnMe.spring.security.auth.manager.RefreshTokenManager; +import PerfumeOnMe.spring.security.auth.provider.JwtTokenProvider; +import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/handler/JwtAccessDeniedHandler.java b/src/main/java/PerfumeOnMe/spring/security/auth/handler/JwtAccessDeniedHandler.java similarity index 96% rename from src/main/java/PerfumeOnMe/spring/config/security/auth/handler/JwtAccessDeniedHandler.java rename to src/main/java/PerfumeOnMe/spring/security/auth/handler/JwtAccessDeniedHandler.java index c02a51f..098ecbd 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/handler/JwtAccessDeniedHandler.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/handler/JwtAccessDeniedHandler.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config.security.auth.handler; +package PerfumeOnMe.spring.security.auth.handler; import java.io.IOException; diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/handler/JwtAuthenticationEntryPoint.java b/src/main/java/PerfumeOnMe/spring/security/auth/handler/JwtAuthenticationEntryPoint.java similarity index 96% rename from src/main/java/PerfumeOnMe/spring/config/security/auth/handler/JwtAuthenticationEntryPoint.java rename to src/main/java/PerfumeOnMe/spring/security/auth/handler/JwtAuthenticationEntryPoint.java index 2975bb9..4ec543e 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/handler/JwtAuthenticationEntryPoint.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/handler/JwtAuthenticationEntryPoint.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config.security.auth.handler; +package PerfumeOnMe.spring.security.auth.handler; import java.io.IOException; diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/manager/LogoutAccessTokenManager.java b/src/main/java/PerfumeOnMe/spring/security/auth/manager/LogoutAccessTokenManager.java similarity index 90% rename from src/main/java/PerfumeOnMe/spring/config/security/auth/manager/LogoutAccessTokenManager.java rename to src/main/java/PerfumeOnMe/spring/security/auth/manager/LogoutAccessTokenManager.java index 4d6bf78..44206cc 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/manager/LogoutAccessTokenManager.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/manager/LogoutAccessTokenManager.java @@ -1,11 +1,11 @@ -package PerfumeOnMe.spring.config.security.auth.manager; +package PerfumeOnMe.spring.security.auth.manager; import java.time.Duration; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; -import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; +import PerfumeOnMe.spring.security.auth.provider.JwtTokenProvider; import lombok.RequiredArgsConstructor; @Component diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/manager/RefreshTokenManager.java b/src/main/java/PerfumeOnMe/spring/security/auth/manager/RefreshTokenManager.java similarity index 91% rename from src/main/java/PerfumeOnMe/spring/config/security/auth/manager/RefreshTokenManager.java rename to src/main/java/PerfumeOnMe/spring/security/auth/manager/RefreshTokenManager.java index f46c234..27a4190 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/manager/RefreshTokenManager.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/manager/RefreshTokenManager.java @@ -1,11 +1,11 @@ -package PerfumeOnMe.spring.config.security.auth.manager; +package PerfumeOnMe.spring.security.auth.manager; import java.time.Duration; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; -import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; +import PerfumeOnMe.spring.security.auth.provider.JwtTokenProvider; import lombok.RequiredArgsConstructor; /* diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/CustomLoginAuthenticationProvider.java b/src/main/java/PerfumeOnMe/spring/security/auth/provider/CustomLoginAuthenticationProvider.java similarity index 96% rename from src/main/java/PerfumeOnMe/spring/config/security/auth/provider/CustomLoginAuthenticationProvider.java rename to src/main/java/PerfumeOnMe/spring/security/auth/provider/CustomLoginAuthenticationProvider.java index a26e56d..5409b21 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/CustomLoginAuthenticationProvider.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/provider/CustomLoginAuthenticationProvider.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config.security.auth.provider; +package PerfumeOnMe.spring.security.auth.provider; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.BadCredentialsException; diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtAuthenticationProvider.java b/src/main/java/PerfumeOnMe/spring/security/auth/provider/JwtAuthenticationProvider.java similarity index 88% rename from src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtAuthenticationProvider.java rename to src/main/java/PerfumeOnMe/spring/security/auth/provider/JwtAuthenticationProvider.java index 2315a59..d918bb2 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtAuthenticationProvider.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/provider/JwtAuthenticationProvider.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config.security.auth.provider; +package PerfumeOnMe.spring.security.auth.provider; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.core.Authentication; @@ -7,8 +7,8 @@ import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.stereotype.Component; -import PerfumeOnMe.spring.config.security.auth.token.JwtAuthenticationToken; -import PerfumeOnMe.spring.domain.enums.Social; +import PerfumeOnMe.spring.common.enums.Social; +import PerfumeOnMe.spring.security.auth.token.JwtAuthenticationToken; import lombok.RequiredArgsConstructor; /* diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtTokenProvider.java b/src/main/java/PerfumeOnMe/spring/security/auth/provider/JwtTokenProvider.java similarity index 94% rename from src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtTokenProvider.java rename to src/main/java/PerfumeOnMe/spring/security/auth/provider/JwtTokenProvider.java index 55c5f94..2fc9b30 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/provider/JwtTokenProvider.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/provider/JwtTokenProvider.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config.security.auth.provider; +package PerfumeOnMe.spring.security.auth.provider; import java.security.Key; import java.util.Date; @@ -9,8 +9,8 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.config.security.auth.dto.JwtProperties; -import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; +import PerfumeOnMe.spring.security.auth.dto.JwtProperties; +import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/service/LoginService.java b/src/main/java/PerfumeOnMe/spring/security/auth/service/LoginService.java similarity index 64% rename from src/main/java/PerfumeOnMe/spring/config/security/auth/service/LoginService.java rename to src/main/java/PerfumeOnMe/spring/security/auth/service/LoginService.java index 1325abb..4a1944b 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/service/LoginService.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/service/LoginService.java @@ -1,12 +1,12 @@ -package PerfumeOnMe.spring.config.security.auth.service; +package PerfumeOnMe.spring.security.auth.service; import java.io.IOException; import org.springframework.security.core.Authentication; -import PerfumeOnMe.spring.config.security.auth.dto.AuthRequestDTO; -import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; -import PerfumeOnMe.spring.domain.enums.Social; +import PerfumeOnMe.spring.common.enums.Social; +import PerfumeOnMe.spring.security.auth.dto.AuthRequestDTO; +import PerfumeOnMe.spring.security.auth.dto.AuthResponseDTO; import jakarta.servlet.http.HttpServletResponse; public interface LoginService { diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/service/LoginServiceImpl.java b/src/main/java/PerfumeOnMe/spring/security/auth/service/LoginServiceImpl.java similarity index 82% rename from src/main/java/PerfumeOnMe/spring/config/security/auth/service/LoginServiceImpl.java rename to src/main/java/PerfumeOnMe/spring/security/auth/service/LoginServiceImpl.java index 184775b..7d9c2f3 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/service/LoginServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/service/LoginServiceImpl.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config.security.auth.service; +package PerfumeOnMe.spring.security.auth.service; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -10,15 +10,15 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.config.security.auth.converter.AuthConverter; -import PerfumeOnMe.spring.config.security.auth.dto.AuthRequestDTO; -import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; -import PerfumeOnMe.spring.config.security.auth.manager.LogoutAccessTokenManager; -import PerfumeOnMe.spring.config.security.auth.manager.RefreshTokenManager; -import PerfumeOnMe.spring.config.security.auth.provider.CustomLoginAuthenticationProvider; -import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; -import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; -import PerfumeOnMe.spring.domain.enums.Social; +import PerfumeOnMe.spring.common.enums.Social; +import PerfumeOnMe.spring.security.auth.converter.AuthConverter; +import PerfumeOnMe.spring.security.auth.dto.AuthRequestDTO; +import PerfumeOnMe.spring.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.security.auth.manager.LogoutAccessTokenManager; +import PerfumeOnMe.spring.security.auth.manager.RefreshTokenManager; +import PerfumeOnMe.spring.security.auth.provider.CustomLoginAuthenticationProvider; +import PerfumeOnMe.spring.security.auth.provider.JwtTokenProvider; +import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/token/JwtAuthenticationToken.java b/src/main/java/PerfumeOnMe/spring/security/auth/token/JwtAuthenticationToken.java similarity index 92% rename from src/main/java/PerfumeOnMe/spring/config/security/auth/token/JwtAuthenticationToken.java rename to src/main/java/PerfumeOnMe/spring/security/auth/token/JwtAuthenticationToken.java index d74ccb1..52448ff 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/token/JwtAuthenticationToken.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/token/JwtAuthenticationToken.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config.security.auth.token; +package PerfumeOnMe.spring.security.auth.token; import java.util.Collection; @@ -6,7 +6,7 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; -import PerfumeOnMe.spring.domain.enums.Social; +import PerfumeOnMe.spring.common.enums.Social; import lombok.Getter; /* diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/userDetails/CustomUserDetails.java b/src/main/java/PerfumeOnMe/spring/security/auth/userDetails/CustomUserDetails.java similarity index 92% rename from src/main/java/PerfumeOnMe/spring/config/security/auth/userDetails/CustomUserDetails.java rename to src/main/java/PerfumeOnMe/spring/security/auth/userDetails/CustomUserDetails.java index 8612049..c510622 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/userDetails/CustomUserDetails.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/userDetails/CustomUserDetails.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config.security.auth.userDetails; +package PerfumeOnMe.spring.security.auth.userDetails; import java.util.Collection; import java.util.Collections; @@ -7,7 +7,7 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; -import PerfumeOnMe.spring.domain.enums.Social; +import PerfumeOnMe.spring.common.enums.Social; import lombok.RequiredArgsConstructor; /* diff --git a/src/main/java/PerfumeOnMe/spring/config/security/auth/userDetails/CustomUserDetailsService.java b/src/main/java/PerfumeOnMe/spring/security/auth/userDetails/CustomUserDetailsService.java similarity index 85% rename from src/main/java/PerfumeOnMe/spring/config/security/auth/userDetails/CustomUserDetailsService.java rename to src/main/java/PerfumeOnMe/spring/security/auth/userDetails/CustomUserDetailsService.java index d729c37..b530342 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/auth/userDetails/CustomUserDetailsService.java +++ b/src/main/java/PerfumeOnMe/spring/security/auth/userDetails/CustomUserDetailsService.java @@ -1,12 +1,12 @@ -package PerfumeOnMe.spring.config.security.auth.userDetails; +package PerfumeOnMe.spring.security.auth.userDetails; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; -import PerfumeOnMe.spring.domain.User; -import PerfumeOnMe.spring.repository.user.UserRepository; +import PerfumeOnMe.spring.user.domain.User; +import PerfumeOnMe.spring.user.repository.UserRepository; import lombok.RequiredArgsConstructor; /* diff --git a/src/main/java/PerfumeOnMe/spring/config/security/oauth/controller/OAuthController.java b/src/main/java/PerfumeOnMe/spring/security/oauth/controller/OAuthController.java similarity index 85% rename from src/main/java/PerfumeOnMe/spring/config/security/oauth/controller/OAuthController.java rename to src/main/java/PerfumeOnMe/spring/security/oauth/controller/OAuthController.java index d82e034..c2b324e 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/oauth/controller/OAuthController.java +++ b/src/main/java/PerfumeOnMe/spring/security/oauth/controller/OAuthController.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config.security.oauth.controller; +package PerfumeOnMe.spring.security.oauth.controller; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; @@ -8,11 +8,11 @@ import org.springframework.web.bind.annotation.RestController; import PerfumeOnMe.spring.apiPayload.ApiResponse; -import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; -import PerfumeOnMe.spring.config.security.oauth.service.OAuthService; -import PerfumeOnMe.spring.config.security.oauth.service.OAuthServiceFactory; -import PerfumeOnMe.spring.config.security.oauth.util.OAuthProviderResolver; -import PerfumeOnMe.spring.domain.enums.Social; +import PerfumeOnMe.spring.common.enums.Social; +import PerfumeOnMe.spring.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.security.oauth.service.OAuthService; +import PerfumeOnMe.spring.security.oauth.service.OAuthServiceFactory; +import PerfumeOnMe.spring.security.oauth.util.OAuthProviderResolver; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import jakarta.servlet.http.HttpServletResponse; diff --git a/src/main/java/PerfumeOnMe/spring/config/security/oauth/converter/OAuthConverter.java b/src/main/java/PerfumeOnMe/spring/security/oauth/converter/OAuthConverter.java similarity index 71% rename from src/main/java/PerfumeOnMe/spring/config/security/oauth/converter/OAuthConverter.java rename to src/main/java/PerfumeOnMe/spring/security/oauth/converter/OAuthConverter.java index 690e52f..86e92bf 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/oauth/converter/OAuthConverter.java +++ b/src/main/java/PerfumeOnMe/spring/security/oauth/converter/OAuthConverter.java @@ -1,7 +1,7 @@ -package PerfumeOnMe.spring.config.security.oauth.converter; +package PerfumeOnMe.spring.security.oauth.converter; -import PerfumeOnMe.spring.domain.User; -import PerfumeOnMe.spring.domain.enums.Social; +import PerfumeOnMe.spring.common.enums.Social; +import PerfumeOnMe.spring.user.domain.User; public class OAuthConverter { diff --git a/src/main/java/PerfumeOnMe/spring/config/security/oauth/dto/KakaoProperties.java b/src/main/java/PerfumeOnMe/spring/security/oauth/dto/KakaoProperties.java similarity index 91% rename from src/main/java/PerfumeOnMe/spring/config/security/oauth/dto/KakaoProperties.java rename to src/main/java/PerfumeOnMe/spring/security/oauth/dto/KakaoProperties.java index 056932d..93ab15b 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/oauth/dto/KakaoProperties.java +++ b/src/main/java/PerfumeOnMe/spring/security/oauth/dto/KakaoProperties.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config.security.oauth.dto; +package PerfumeOnMe.spring.security.oauth.dto; import java.util.List; diff --git a/src/main/java/PerfumeOnMe/spring/config/security/oauth/dto/KakaoResponseDTO.java b/src/main/java/PerfumeOnMe/spring/security/oauth/dto/KakaoResponseDTO.java similarity index 96% rename from src/main/java/PerfumeOnMe/spring/config/security/oauth/dto/KakaoResponseDTO.java rename to src/main/java/PerfumeOnMe/spring/security/oauth/dto/KakaoResponseDTO.java index 62dd35f..2dfce25 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/oauth/dto/KakaoResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/security/oauth/dto/KakaoResponseDTO.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config.security.oauth.dto; +package PerfumeOnMe.spring.security.oauth.dto; import java.time.LocalDateTime; diff --git a/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/KakaoService.java b/src/main/java/PerfumeOnMe/spring/security/oauth/service/KakaoService.java similarity index 74% rename from src/main/java/PerfumeOnMe/spring/config/security/oauth/service/KakaoService.java rename to src/main/java/PerfumeOnMe/spring/security/oauth/service/KakaoService.java index 2171f9e..df13b91 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/KakaoService.java +++ b/src/main/java/PerfumeOnMe/spring/security/oauth/service/KakaoService.java @@ -1,22 +1,22 @@ -package PerfumeOnMe.spring.config.security.oauth.service; +package PerfumeOnMe.spring.security.oauth.service; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.stereotype.Service; -import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; -import PerfumeOnMe.spring.config.security.auth.manager.LogoutAccessTokenManager; -import PerfumeOnMe.spring.config.security.auth.manager.RefreshTokenManager; -import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; -import PerfumeOnMe.spring.config.security.auth.service.LoginService; -import PerfumeOnMe.spring.config.security.auth.token.JwtAuthenticationToken; -import PerfumeOnMe.spring.config.security.oauth.converter.OAuthConverter; -import PerfumeOnMe.spring.config.security.oauth.dto.KakaoResponseDTO; -import PerfumeOnMe.spring.config.security.oauth.util.KakaoClient; -import PerfumeOnMe.spring.domain.User; -import PerfumeOnMe.spring.domain.enums.Social; -import PerfumeOnMe.spring.repository.user.UserRepository; +import PerfumeOnMe.spring.common.enums.Social; +import PerfumeOnMe.spring.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.security.auth.manager.LogoutAccessTokenManager; +import PerfumeOnMe.spring.security.auth.manager.RefreshTokenManager; +import PerfumeOnMe.spring.security.auth.provider.JwtTokenProvider; +import PerfumeOnMe.spring.security.auth.service.LoginService; +import PerfumeOnMe.spring.security.auth.token.JwtAuthenticationToken; +import PerfumeOnMe.spring.security.oauth.converter.OAuthConverter; +import PerfumeOnMe.spring.security.oauth.dto.KakaoResponseDTO; +import PerfumeOnMe.spring.security.oauth.util.KakaoClient; +import PerfumeOnMe.spring.user.domain.User; +import PerfumeOnMe.spring.user.repository.UserRepository; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/OAuthService.java b/src/main/java/PerfumeOnMe/spring/security/oauth/service/OAuthService.java similarity index 60% rename from src/main/java/PerfumeOnMe/spring/config/security/oauth/service/OAuthService.java rename to src/main/java/PerfumeOnMe/spring/security/oauth/service/OAuthService.java index 7f29355..814f049 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/OAuthService.java +++ b/src/main/java/PerfumeOnMe/spring/security/oauth/service/OAuthService.java @@ -1,7 +1,7 @@ -package PerfumeOnMe.spring.config.security.oauth.service; +package PerfumeOnMe.spring.security.oauth.service; -import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; -import PerfumeOnMe.spring.domain.enums.Social; +import PerfumeOnMe.spring.common.enums.Social; +import PerfumeOnMe.spring.security.auth.dto.AuthResponseDTO; import jakarta.servlet.http.HttpServletResponse; /* diff --git a/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/OAuthServiceFactory.java b/src/main/java/PerfumeOnMe/spring/security/oauth/service/OAuthServiceFactory.java similarity index 86% rename from src/main/java/PerfumeOnMe/spring/config/security/oauth/service/OAuthServiceFactory.java rename to src/main/java/PerfumeOnMe/spring/security/oauth/service/OAuthServiceFactory.java index 7cf9f81..3a690fd 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/oauth/service/OAuthServiceFactory.java +++ b/src/main/java/PerfumeOnMe/spring/security/oauth/service/OAuthServiceFactory.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config.security.oauth.service; +package PerfumeOnMe.spring.security.oauth.service; import java.util.List; import java.util.Map; @@ -7,7 +7,7 @@ import org.springframework.stereotype.Component; -import PerfumeOnMe.spring.domain.enums.Social; +import PerfumeOnMe.spring.common.enums.Social; /* Social과 OAuthService 구현체를 맵으로 저장 diff --git a/src/main/java/PerfumeOnMe/spring/config/security/oauth/util/KakaoClient.java b/src/main/java/PerfumeOnMe/spring/security/oauth/util/KakaoClient.java similarity index 93% rename from src/main/java/PerfumeOnMe/spring/config/security/oauth/util/KakaoClient.java rename to src/main/java/PerfumeOnMe/spring/security/oauth/util/KakaoClient.java index 2bf2db6..c81bafa 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/oauth/util/KakaoClient.java +++ b/src/main/java/PerfumeOnMe/spring/security/oauth/util/KakaoClient.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.config.security.oauth.util; +package PerfumeOnMe.spring.security.oauth.util; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -14,8 +14,8 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.config.security.oauth.dto.KakaoProperties; -import PerfumeOnMe.spring.config.security.oauth.dto.KakaoResponseDTO; +import PerfumeOnMe.spring.security.oauth.dto.KakaoProperties; +import PerfumeOnMe.spring.security.oauth.dto.KakaoResponseDTO; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/PerfumeOnMe/spring/config/security/oauth/util/OAuthProviderResolver.java b/src/main/java/PerfumeOnMe/spring/security/oauth/util/OAuthProviderResolver.java similarity index 82% rename from src/main/java/PerfumeOnMe/spring/config/security/oauth/util/OAuthProviderResolver.java rename to src/main/java/PerfumeOnMe/spring/security/oauth/util/OAuthProviderResolver.java index 97c4e45..fd274a7 100644 --- a/src/main/java/PerfumeOnMe/spring/config/security/oauth/util/OAuthProviderResolver.java +++ b/src/main/java/PerfumeOnMe/spring/security/oauth/util/OAuthProviderResolver.java @@ -1,8 +1,8 @@ -package PerfumeOnMe.spring.config.security.oauth.util; +package PerfumeOnMe.spring.security.oauth.util; import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.domain.enums.Social; +import PerfumeOnMe.spring.common.enums.Social; /* String으로 받아온 provider(ex. kakao)를 Social Enum으로 반환 diff --git a/src/main/java/PerfumeOnMe/spring/converter/UserConverter.java b/src/main/java/PerfumeOnMe/spring/user/converter/UserConverter.java similarity index 87% rename from src/main/java/PerfumeOnMe/spring/converter/UserConverter.java rename to src/main/java/PerfumeOnMe/spring/user/converter/UserConverter.java index ba47b3c..c7bec91 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/UserConverter.java +++ b/src/main/java/PerfumeOnMe/spring/user/converter/UserConverter.java @@ -1,10 +1,10 @@ -package PerfumeOnMe.spring.converter; +package PerfumeOnMe.spring.user.converter; import java.util.List; import java.util.stream.Collectors; -import PerfumeOnMe.spring.domain.User; -import PerfumeOnMe.spring.web.dto.user.UserResponseDTO; +import PerfumeOnMe.spring.user.domain.User; +import PerfumeOnMe.spring.user.web.dto.UserResponseDTO; public class UserConverter { diff --git a/src/main/java/PerfumeOnMe/spring/converter/UserNoteConverter.java b/src/main/java/PerfumeOnMe/spring/user/converter/UserNoteConverter.java similarity index 56% rename from src/main/java/PerfumeOnMe/spring/converter/UserNoteConverter.java rename to src/main/java/PerfumeOnMe/spring/user/converter/UserNoteConverter.java index e35f84d..634f7ed 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/UserNoteConverter.java +++ b/src/main/java/PerfumeOnMe/spring/user/converter/UserNoteConverter.java @@ -1,7 +1,7 @@ -package PerfumeOnMe.spring.converter; +package PerfumeOnMe.spring.user.converter; -import PerfumeOnMe.spring.domain.Note; -import PerfumeOnMe.spring.domain.mapping.UserNote; +import PerfumeOnMe.spring.fragrance.domain.Note; +import PerfumeOnMe.spring.user.domain.mapping.UserNote; public class UserNoteConverter { diff --git a/src/main/java/PerfumeOnMe/spring/domain/User.java b/src/main/java/PerfumeOnMe/spring/user/domain/User.java similarity index 82% rename from src/main/java/PerfumeOnMe/spring/domain/User.java rename to src/main/java/PerfumeOnMe/spring/user/domain/User.java index 8f78bf7..2a8402b 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/User.java +++ b/src/main/java/PerfumeOnMe/spring/user/domain/User.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.domain; +package PerfumeOnMe.spring.user.domain; import java.util.ArrayList; import java.util.List; @@ -6,15 +6,18 @@ import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import PerfumeOnMe.spring.domain.base.BaseEntity; -import PerfumeOnMe.spring.domain.enums.Age; -import PerfumeOnMe.spring.domain.enums.Social; -import PerfumeOnMe.spring.domain.enums.UserGender; -import PerfumeOnMe.spring.domain.mapping.Diary; -import PerfumeOnMe.spring.domain.mapping.UserFragrance; -import PerfumeOnMe.spring.domain.mapping.UserNote; -import PerfumeOnMe.spring.domain.mapping.UserTerms; -import PerfumeOnMe.spring.web.dto.user.UserRequestDTO; +import PerfumeOnMe.spring.chatbot.domain.ChatMessage; +import PerfumeOnMe.spring.common.base.BaseEntity; +import PerfumeOnMe.spring.common.enums.Age; +import PerfumeOnMe.spring.common.enums.Social; +import PerfumeOnMe.spring.common.enums.UserGender; +import PerfumeOnMe.spring.diary.domain.Diary; +import PerfumeOnMe.spring.imagekeyword.domain.ImageKeyword; +import PerfumeOnMe.spring.pbti.domain.PBTI; +import PerfumeOnMe.spring.user.domain.mapping.UserFragrance; +import PerfumeOnMe.spring.user.domain.mapping.UserNote; +import PerfumeOnMe.spring.user.web.dto.UserRequestDTO; +import PerfumeOnMe.spring.workshop.domain.Workshop; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -78,10 +81,6 @@ public class User extends BaseEntity { @Builder.Default private List userFragranceList = new ArrayList<>(); - @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) - @Builder.Default - private List userTermsList = new ArrayList<>(); - @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) @Builder.Default private List diaryList = new ArrayList<>(); diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/UserFragrance.java b/src/main/java/PerfumeOnMe/spring/user/domain/mapping/UserFragrance.java similarity index 83% rename from src/main/java/PerfumeOnMe/spring/domain/mapping/UserFragrance.java rename to src/main/java/PerfumeOnMe/spring/user/domain/mapping/UserFragrance.java index 1d63668..9623501 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/UserFragrance.java +++ b/src/main/java/PerfumeOnMe/spring/user/domain/mapping/UserFragrance.java @@ -1,11 +1,11 @@ -package PerfumeOnMe.spring.domain.mapping; +package PerfumeOnMe.spring.user.domain.mapping; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import PerfumeOnMe.spring.domain.Fragrance; -import PerfumeOnMe.spring.domain.User; -import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.common.base.BaseEntity; +import PerfumeOnMe.spring.fragrance.domain.Fragrance; +import PerfumeOnMe.spring.user.domain.User; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; diff --git a/src/main/java/PerfumeOnMe/spring/domain/mapping/UserNote.java b/src/main/java/PerfumeOnMe/spring/user/domain/mapping/UserNote.java similarity index 87% rename from src/main/java/PerfumeOnMe/spring/domain/mapping/UserNote.java rename to src/main/java/PerfumeOnMe/spring/user/domain/mapping/UserNote.java index 135c9b4..e0a496e 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/mapping/UserNote.java +++ b/src/main/java/PerfumeOnMe/spring/user/domain/mapping/UserNote.java @@ -1,11 +1,11 @@ -package PerfumeOnMe.spring.domain.mapping; +package PerfumeOnMe.spring.user.domain.mapping; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import PerfumeOnMe.spring.domain.Note; -import PerfumeOnMe.spring.domain.User; -import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.common.base.BaseEntity; +import PerfumeOnMe.spring.fragrance.domain.Note; +import PerfumeOnMe.spring.user.domain.User; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; diff --git a/src/main/java/PerfumeOnMe/spring/repository/user/UserRepository.java b/src/main/java/PerfumeOnMe/spring/user/repository/UserRepository.java similarity index 78% rename from src/main/java/PerfumeOnMe/spring/repository/user/UserRepository.java rename to src/main/java/PerfumeOnMe/spring/user/repository/UserRepository.java index 5ceeb1e..819d424 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/user/UserRepository.java +++ b/src/main/java/PerfumeOnMe/spring/user/repository/UserRepository.java @@ -1,10 +1,10 @@ -package PerfumeOnMe.spring.repository.user; +package PerfumeOnMe.spring.user.repository; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -import PerfumeOnMe.spring.domain.User; +import PerfumeOnMe.spring.user.domain.User; public interface UserRepository extends JpaRepository, UserRepositoryCustom { diff --git a/src/main/java/PerfumeOnMe/spring/repository/user/UserRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/user/repository/UserRepositoryCustom.java similarity index 50% rename from src/main/java/PerfumeOnMe/spring/repository/user/UserRepositoryCustom.java rename to src/main/java/PerfumeOnMe/spring/user/repository/UserRepositoryCustom.java index b87443c..50f8c2d 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/user/UserRepositoryCustom.java +++ b/src/main/java/PerfumeOnMe/spring/user/repository/UserRepositoryCustom.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.repository.user; +package PerfumeOnMe.spring.user.repository; public interface UserRepositoryCustom { diff --git a/src/main/java/PerfumeOnMe/spring/repository/user/UserRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/user/repository/UserRepositoryImpl.java similarity index 81% rename from src/main/java/PerfumeOnMe/spring/repository/user/UserRepositoryImpl.java rename to src/main/java/PerfumeOnMe/spring/user/repository/UserRepositoryImpl.java index 5df4026..889ab33 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/user/UserRepositoryImpl.java +++ b/src/main/java/PerfumeOnMe/spring/user/repository/UserRepositoryImpl.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.repository.user; +package PerfumeOnMe.spring.user.repository; import org.springframework.stereotype.Repository; diff --git a/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepository.java b/src/main/java/PerfumeOnMe/spring/user/repository/userFragrance/UserFragranceRepository.java similarity index 73% rename from src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepository.java rename to src/main/java/PerfumeOnMe/spring/user/repository/userFragrance/UserFragranceRepository.java index 67a0397..a2c8a65 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/userFragrance/UserFragranceRepository.java +++ b/src/main/java/PerfumeOnMe/spring/user/repository/userFragrance/UserFragranceRepository.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.repository.userFragrance; +package PerfumeOnMe.spring.user.repository.userFragrance; import java.util.Optional; @@ -6,9 +6,9 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import PerfumeOnMe.spring.domain.Fragrance; -import PerfumeOnMe.spring.domain.User; -import PerfumeOnMe.spring.domain.mapping.UserFragrance; +import PerfumeOnMe.spring.fragrance.domain.Fragrance; +import PerfumeOnMe.spring.user.domain.User; +import PerfumeOnMe.spring.user.domain.mapping.UserFragrance; public interface UserFragranceRepository extends JpaRepository, UserFragranceRepositoryCustom { boolean existsByUserAndFragrance(User user, Fragrance fragrance); diff --git a/src/main/java/PerfumeOnMe/spring/user/repository/userFragrance/UserFragranceRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/user/repository/userFragrance/UserFragranceRepositoryCustom.java new file mode 100644 index 0000000..aa8eb92 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/repository/userFragrance/UserFragranceRepositoryCustom.java @@ -0,0 +1,5 @@ +package PerfumeOnMe.spring.user.repository.userFragrance; + +public interface UserFragranceRepositoryCustom { + +} diff --git a/src/main/java/PerfumeOnMe/spring/repository/userNote/UserNoteRepository.java b/src/main/java/PerfumeOnMe/spring/user/repository/userNote/UserNoteRepository.java similarity index 53% rename from src/main/java/PerfumeOnMe/spring/repository/userNote/UserNoteRepository.java rename to src/main/java/PerfumeOnMe/spring/user/repository/userNote/UserNoteRepository.java index fef4d66..8765d76 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/userNote/UserNoteRepository.java +++ b/src/main/java/PerfumeOnMe/spring/user/repository/userNote/UserNoteRepository.java @@ -1,9 +1,9 @@ -package PerfumeOnMe.spring.repository.userNote; +package PerfumeOnMe.spring.user.repository.userNote; import org.springframework.data.jpa.repository.JpaRepository; -import PerfumeOnMe.spring.domain.User; -import PerfumeOnMe.spring.domain.mapping.UserNote; +import PerfumeOnMe.spring.user.domain.User; +import PerfumeOnMe.spring.user.domain.mapping.UserNote; public interface UserNoteRepository extends JpaRepository { diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserService.java b/src/main/java/PerfumeOnMe/spring/user/service/UserService.java similarity index 66% rename from src/main/java/PerfumeOnMe/spring/service/user/UserService.java rename to src/main/java/PerfumeOnMe/spring/user/service/UserService.java index e70e1d8..f6227b4 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserService.java +++ b/src/main/java/PerfumeOnMe/spring/user/service/UserService.java @@ -1,11 +1,11 @@ -package PerfumeOnMe.spring.service.user; - -import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; -import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; -import PerfumeOnMe.spring.web.dto.fragrance.FragranceRequestDTO; -import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; -import PerfumeOnMe.spring.web.dto.user.UserRequestDTO; -import PerfumeOnMe.spring.web.dto.user.UserResponseDTO; +package PerfumeOnMe.spring.user.service; + +import PerfumeOnMe.spring.fragrance.web.dto.FragranceRequestDTO; +import PerfumeOnMe.spring.fragrance.web.dto.FragranceResponseDTO; +import PerfumeOnMe.spring.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; +import PerfumeOnMe.spring.user.web.dto.UserRequestDTO; +import PerfumeOnMe.spring.user.web.dto.UserResponseDTO; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; diff --git a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java b/src/main/java/PerfumeOnMe/spring/user/service/UserServiceImpl.java similarity index 83% rename from src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java rename to src/main/java/PerfumeOnMe/spring/user/service/UserServiceImpl.java index b5ef5e8..f914039 100644 --- a/src/main/java/PerfumeOnMe/spring/service/user/UserServiceImpl.java +++ b/src/main/java/PerfumeOnMe/spring/user/service/UserServiceImpl.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service.user; +package PerfumeOnMe.spring.user.service; import java.util.List; import java.util.Optional; @@ -14,30 +14,30 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; -import PerfumeOnMe.spring.config.security.auth.manager.LogoutAccessTokenManager; -import PerfumeOnMe.spring.config.security.auth.manager.RefreshTokenManager; -import PerfumeOnMe.spring.config.security.auth.provider.JwtTokenProvider; -import PerfumeOnMe.spring.config.security.auth.service.LoginService; -import PerfumeOnMe.spring.config.security.auth.token.JwtAuthenticationToken; -import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; -import PerfumeOnMe.spring.converter.FragranceConverter; -import PerfumeOnMe.spring.converter.UserConverter; -import PerfumeOnMe.spring.converter.UserNoteConverter; -import PerfumeOnMe.spring.domain.Fragrance; -import PerfumeOnMe.spring.domain.Note; -import PerfumeOnMe.spring.domain.User; -import PerfumeOnMe.spring.domain.enums.Social; -import PerfumeOnMe.spring.domain.mapping.UserFragrance; -import PerfumeOnMe.spring.domain.mapping.UserNote; -import PerfumeOnMe.spring.repository.note.NoteRepository; -import PerfumeOnMe.spring.repository.user.UserRepository; -import PerfumeOnMe.spring.repository.userFragrance.UserFragranceRepository; -import PerfumeOnMe.spring.repository.userNote.UserNoteRepository; -import PerfumeOnMe.spring.web.dto.fragrance.FragranceRequestDTO; -import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; -import PerfumeOnMe.spring.web.dto.user.UserRequestDTO; -import PerfumeOnMe.spring.web.dto.user.UserResponseDTO; +import PerfumeOnMe.spring.common.enums.Social; +import PerfumeOnMe.spring.fragrance.converter.FragranceConverter; +import PerfumeOnMe.spring.fragrance.domain.Fragrance; +import PerfumeOnMe.spring.fragrance.domain.Note; +import PerfumeOnMe.spring.fragrance.repository.note.NoteRepository; +import PerfumeOnMe.spring.fragrance.web.dto.FragranceRequestDTO; +import PerfumeOnMe.spring.fragrance.web.dto.FragranceResponseDTO; +import PerfumeOnMe.spring.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.security.auth.manager.LogoutAccessTokenManager; +import PerfumeOnMe.spring.security.auth.manager.RefreshTokenManager; +import PerfumeOnMe.spring.security.auth.provider.JwtTokenProvider; +import PerfumeOnMe.spring.security.auth.service.LoginService; +import PerfumeOnMe.spring.security.auth.token.JwtAuthenticationToken; +import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; +import PerfumeOnMe.spring.user.converter.UserConverter; +import PerfumeOnMe.spring.user.converter.UserNoteConverter; +import PerfumeOnMe.spring.user.domain.User; +import PerfumeOnMe.spring.user.domain.mapping.UserFragrance; +import PerfumeOnMe.spring.user.domain.mapping.UserNote; +import PerfumeOnMe.spring.user.repository.UserRepository; +import PerfumeOnMe.spring.user.repository.userFragrance.UserFragranceRepository; +import PerfumeOnMe.spring.user.repository.userNote.UserNoteRepository; +import PerfumeOnMe.spring.user.web.dto.UserRequestDTO; +import PerfumeOnMe.spring.user.web.dto.UserResponseDTO; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUser.java b/src/main/java/PerfumeOnMe/spring/user/validation/annotation/ExistUser.java similarity index 77% rename from src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUser.java rename to src/main/java/PerfumeOnMe/spring/user/validation/annotation/ExistUser.java index dffa901..492c8ef 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUser.java +++ b/src/main/java/PerfumeOnMe/spring/user/validation/annotation/ExistUser.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.validation.annotation; +package PerfumeOnMe.spring.user.validation.annotation; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; @@ -6,7 +6,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import PerfumeOnMe.spring.validation.validator.ExistUserValidator; +import PerfumeOnMe.spring.user.validation.validator.ExistUserValidator; import jakarta.validation.Constraint; @Target({ElementType.FIELD, ElementType.PARAMETER}) diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUserAge.java b/src/main/java/PerfumeOnMe/spring/user/validation/annotation/ExistUserAge.java similarity index 83% rename from src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUserAge.java rename to src/main/java/PerfumeOnMe/spring/user/validation/annotation/ExistUserAge.java index 4ef4bc6..f231389 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUserAge.java +++ b/src/main/java/PerfumeOnMe/spring/user/validation/annotation/ExistUserAge.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.validation.annotation; +package PerfumeOnMe.spring.user.validation.annotation; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; @@ -6,7 +6,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import PerfumeOnMe.spring.validation.validator.ExistUserAgeValidator; +import PerfumeOnMe.spring.user.validation.validator.ExistUserAgeValidator; import jakarta.validation.Constraint; import jakarta.validation.Payload; diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUserGender.java b/src/main/java/PerfumeOnMe/spring/user/validation/annotation/ExistUserGender.java similarity index 82% rename from src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUserGender.java rename to src/main/java/PerfumeOnMe/spring/user/validation/annotation/ExistUserGender.java index adeb1f4..e94b6d9 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/annotation/ExistUserGender.java +++ b/src/main/java/PerfumeOnMe/spring/user/validation/annotation/ExistUserGender.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.validation.annotation; +package PerfumeOnMe.spring.user.validation.annotation; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; @@ -6,7 +6,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import PerfumeOnMe.spring.validation.validator.ExistUserGenderValidator; +import PerfumeOnMe.spring.user.validation.validator.ExistUserGenderValidator; import jakarta.validation.Constraint; import jakarta.validation.Payload; diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidUserNote.java b/src/main/java/PerfumeOnMe/spring/user/validation/annotation/ValidUserNote.java similarity index 82% rename from src/main/java/PerfumeOnMe/spring/validation/annotation/ValidUserNote.java rename to src/main/java/PerfumeOnMe/spring/user/validation/annotation/ValidUserNote.java index 8dc4e60..1795db1 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/annotation/ValidUserNote.java +++ b/src/main/java/PerfumeOnMe/spring/user/validation/annotation/ValidUserNote.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.validation.annotation; +package PerfumeOnMe.spring.user.validation.annotation; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; @@ -6,7 +6,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import PerfumeOnMe.spring.validation.validator.ValidUserNoteValidator; +import PerfumeOnMe.spring.user.validation.validator.ValidUserNoteValidator; import jakarta.validation.Constraint; import jakarta.validation.Payload; diff --git a/src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserAgeValidator.java b/src/main/java/PerfumeOnMe/spring/user/validation/validator/ExistUserAgeValidator.java similarity index 79% rename from src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserAgeValidator.java rename to src/main/java/PerfumeOnMe/spring/user/validation/validator/ExistUserAgeValidator.java index 060bc1c..3113443 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserAgeValidator.java +++ b/src/main/java/PerfumeOnMe/spring/user/validation/validator/ExistUserAgeValidator.java @@ -1,7 +1,7 @@ -package PerfumeOnMe.spring.validation.validator; +package PerfumeOnMe.spring.user.validation.validator; -import PerfumeOnMe.spring.domain.enums.Age; -import PerfumeOnMe.spring.validation.annotation.ExistUserAge; +import PerfumeOnMe.spring.common.enums.Age; +import PerfumeOnMe.spring.user.validation.annotation.ExistUserAge; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; diff --git a/src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserGenderValidator.java b/src/main/java/PerfumeOnMe/spring/user/validation/validator/ExistUserGenderValidator.java similarity index 77% rename from src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserGenderValidator.java rename to src/main/java/PerfumeOnMe/spring/user/validation/validator/ExistUserGenderValidator.java index d28d7a1..2f78b63 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserGenderValidator.java +++ b/src/main/java/PerfumeOnMe/spring/user/validation/validator/ExistUserGenderValidator.java @@ -1,7 +1,7 @@ -package PerfumeOnMe.spring.validation.validator; +package PerfumeOnMe.spring.user.validation.validator; -import PerfumeOnMe.spring.domain.enums.UserGender; -import PerfumeOnMe.spring.validation.annotation.ExistUserGender; +import PerfumeOnMe.spring.common.enums.UserGender; +import PerfumeOnMe.spring.user.validation.annotation.ExistUserGender; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; diff --git a/src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserValidator.java b/src/main/java/PerfumeOnMe/spring/user/validation/validator/ExistUserValidator.java similarity index 77% rename from src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserValidator.java rename to src/main/java/PerfumeOnMe/spring/user/validation/validator/ExistUserValidator.java index f4f7d29..acfa9a8 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/validator/ExistUserValidator.java +++ b/src/main/java/PerfumeOnMe/spring/user/validation/validator/ExistUserValidator.java @@ -1,8 +1,8 @@ -package PerfumeOnMe.spring.validation.validator; +package PerfumeOnMe.spring.user.validation.validator; import org.springframework.stereotype.Component; -import PerfumeOnMe.spring.validation.annotation.ExistUser; +import PerfumeOnMe.spring.user.validation.annotation.ExistUser; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/PerfumeOnMe/spring/validation/validator/ValidUserNoteValidator.java b/src/main/java/PerfumeOnMe/spring/user/validation/validator/ValidUserNoteValidator.java similarity index 80% rename from src/main/java/PerfumeOnMe/spring/validation/validator/ValidUserNoteValidator.java rename to src/main/java/PerfumeOnMe/spring/user/validation/validator/ValidUserNoteValidator.java index d1ec296..eeec0f5 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/validator/ValidUserNoteValidator.java +++ b/src/main/java/PerfumeOnMe/spring/user/validation/validator/ValidUserNoteValidator.java @@ -1,8 +1,8 @@ -package PerfumeOnMe.spring.validation.validator; +package PerfumeOnMe.spring.user.validation.validator; import java.util.List; -import PerfumeOnMe.spring.validation.annotation.ValidUserNote; +import PerfumeOnMe.spring.user.validation.annotation.ValidUserNote; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java b/src/main/java/PerfumeOnMe/spring/user/web/controller/UserController.java similarity index 95% rename from src/main/java/PerfumeOnMe/spring/web/controller/UserController.java rename to src/main/java/PerfumeOnMe/spring/user/web/controller/UserController.java index f9d2faa..bf1a691 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/UserController.java +++ b/src/main/java/PerfumeOnMe/spring/user/web/controller/UserController.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.controller; +package PerfumeOnMe.spring.user.web.controller; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -15,13 +15,13 @@ import PerfumeOnMe.spring.apiPayload.ApiResponse; import PerfumeOnMe.spring.apiPayload.code.status.SuccessStatus; -import PerfumeOnMe.spring.config.security.auth.dto.AuthResponseDTO; -import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; -import PerfumeOnMe.spring.service.user.UserService; -import PerfumeOnMe.spring.web.dto.fragrance.FragranceRequestDTO; -import PerfumeOnMe.spring.web.dto.fragrance.FragranceResponseDTO; -import PerfumeOnMe.spring.web.dto.user.UserRequestDTO; -import PerfumeOnMe.spring.web.dto.user.UserResponseDTO; +import PerfumeOnMe.spring.fragrance.web.dto.FragranceRequestDTO; +import PerfumeOnMe.spring.fragrance.web.dto.FragranceResponseDTO; +import PerfumeOnMe.spring.security.auth.dto.AuthResponseDTO; +import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; +import PerfumeOnMe.spring.user.service.UserService; +import PerfumeOnMe.spring.user.web.dto.UserRequestDTO; +import PerfumeOnMe.spring.user.web.dto.UserResponseDTO; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java b/src/main/java/PerfumeOnMe/spring/user/web/dto/UserRequestDTO.java similarity index 91% rename from src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java rename to src/main/java/PerfumeOnMe/spring/user/web/dto/UserRequestDTO.java index a83c4bd..8a65941 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/user/UserRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/user/web/dto/UserRequestDTO.java @@ -1,10 +1,10 @@ -package PerfumeOnMe.spring.web.dto.user; +package PerfumeOnMe.spring.user.web.dto; import java.util.List; -import PerfumeOnMe.spring.validation.annotation.ExistUserAge; -import PerfumeOnMe.spring.validation.annotation.ExistUserGender; -import PerfumeOnMe.spring.validation.annotation.ValidUserNote; +import PerfumeOnMe.spring.user.validation.annotation.ExistUserAge; +import PerfumeOnMe.spring.user.validation.annotation.ExistUserGender; +import PerfumeOnMe.spring.user.validation.annotation.ValidUserNote; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/user/UserResponseDTO.java b/src/main/java/PerfumeOnMe/spring/user/web/dto/UserResponseDTO.java similarity index 92% rename from src/main/java/PerfumeOnMe/spring/web/dto/user/UserResponseDTO.java rename to src/main/java/PerfumeOnMe/spring/user/web/dto/UserResponseDTO.java index 2d2b509..edcc63f 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/user/UserResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/user/web/dto/UserResponseDTO.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.dto.user; +package PerfumeOnMe.spring.user.web.dto; import java.util.List; diff --git a/src/main/java/PerfumeOnMe/spring/domain/Uuid.java b/src/main/java/PerfumeOnMe/spring/uuid/domain/Uuid.java similarity index 85% rename from src/main/java/PerfumeOnMe/spring/domain/Uuid.java rename to src/main/java/PerfumeOnMe/spring/uuid/domain/Uuid.java index bcc6177..fe811db 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Uuid.java +++ b/src/main/java/PerfumeOnMe/spring/uuid/domain/Uuid.java @@ -1,6 +1,6 @@ -package PerfumeOnMe.spring.domain; +package PerfumeOnMe.spring.uuid.domain; -import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.common.base.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; diff --git a/src/main/java/PerfumeOnMe/spring/repository/uuid/UuidRepository.java b/src/main/java/PerfumeOnMe/spring/uuid/repository/UuidRepository.java similarity index 69% rename from src/main/java/PerfumeOnMe/spring/repository/uuid/UuidRepository.java rename to src/main/java/PerfumeOnMe/spring/uuid/repository/UuidRepository.java index 4beeab2..6a33c09 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/uuid/UuidRepository.java +++ b/src/main/java/PerfumeOnMe/spring/uuid/repository/UuidRepository.java @@ -1,10 +1,10 @@ -package PerfumeOnMe.spring.repository.uuid; +package PerfumeOnMe.spring.uuid.repository; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -import PerfumeOnMe.spring.domain.Uuid; +import PerfumeOnMe.spring.uuid.domain.Uuid; public interface UuidRepository extends JpaRepository { Optional findByUuid(String uuid); diff --git a/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java b/src/main/java/PerfumeOnMe/spring/workshop/converter/WorkshopConverter.java similarity index 93% rename from src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java rename to src/main/java/PerfumeOnMe/spring/workshop/converter/WorkshopConverter.java index 1c06cd2..a68eff5 100644 --- a/src/main/java/PerfumeOnMe/spring/converter/WorkshopConverter.java +++ b/src/main/java/PerfumeOnMe/spring/workshop/converter/WorkshopConverter.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.converter; +package PerfumeOnMe.spring.workshop.converter; import java.util.ArrayList; import java.util.List; @@ -8,12 +8,12 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import PerfumeOnMe.spring.domain.User; -import PerfumeOnMe.spring.domain.Workshop; -import PerfumeOnMe.spring.domain.WorkshopFragrance; -import PerfumeOnMe.spring.service.workshop.WorkshopResult; -import PerfumeOnMe.spring.web.dto.workshop.WorkshopRequestDTO; -import PerfumeOnMe.spring.web.dto.workshop.WorkshopResponseDTO; +import PerfumeOnMe.spring.user.domain.User; +import PerfumeOnMe.spring.workshop.domain.Workshop; +import PerfumeOnMe.spring.workshop.domain.WorkshopFragrance; +import PerfumeOnMe.spring.workshop.service.WorkshopResult; +import PerfumeOnMe.spring.workshop.web.dto.WorkshopRequestDTO; +import PerfumeOnMe.spring.workshop.web.dto.WorkshopResponseDTO; public class WorkshopConverter { diff --git a/src/main/java/PerfumeOnMe/spring/domain/Workshop.java b/src/main/java/PerfumeOnMe/spring/workshop/domain/Workshop.java similarity index 93% rename from src/main/java/PerfumeOnMe/spring/domain/Workshop.java rename to src/main/java/PerfumeOnMe/spring/workshop/domain/Workshop.java index 54f4294..da156bc 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/Workshop.java +++ b/src/main/java/PerfumeOnMe/spring/workshop/domain/Workshop.java @@ -1,9 +1,10 @@ -package PerfumeOnMe.spring.domain; +package PerfumeOnMe.spring.workshop.domain; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.common.base.BaseEntity; +import PerfumeOnMe.spring.user.domain.User; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; diff --git a/src/main/java/PerfumeOnMe/spring/domain/WorkshopFragrance.java b/src/main/java/PerfumeOnMe/spring/workshop/domain/WorkshopFragrance.java similarity index 94% rename from src/main/java/PerfumeOnMe/spring/domain/WorkshopFragrance.java rename to src/main/java/PerfumeOnMe/spring/workshop/domain/WorkshopFragrance.java index f6725f5..52081f0 100644 --- a/src/main/java/PerfumeOnMe/spring/domain/WorkshopFragrance.java +++ b/src/main/java/PerfumeOnMe/spring/workshop/domain/WorkshopFragrance.java @@ -1,9 +1,9 @@ -package PerfumeOnMe.spring.domain; +package PerfumeOnMe.spring.workshop.domain; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; -import PerfumeOnMe.spring.domain.base.BaseEntity; +import PerfumeOnMe.spring.common.base.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; diff --git a/src/main/java/PerfumeOnMe/spring/service/redis/WorkshopRedisService.java b/src/main/java/PerfumeOnMe/spring/workshop/redis/WorkshopRedisService.java similarity index 94% rename from src/main/java/PerfumeOnMe/spring/service/redis/WorkshopRedisService.java rename to src/main/java/PerfumeOnMe/spring/workshop/redis/WorkshopRedisService.java index 6567229..a17cd00 100644 --- a/src/main/java/PerfumeOnMe/spring/service/redis/WorkshopRedisService.java +++ b/src/main/java/PerfumeOnMe/spring/workshop/redis/WorkshopRedisService.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service.redis; +package PerfumeOnMe.spring.workshop.redis; import java.time.Duration; @@ -9,8 +9,7 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.web.dto.workshop.WorkshopRequestDTO; -import PerfumeOnMe.spring.web.dto.workshop.WorkshopResponseDTO; +import PerfumeOnMe.spring.workshop.web.dto.WorkshopResponseDTO; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopFragranceRepository.java b/src/main/java/PerfumeOnMe/spring/workshop/repository/WorkshopFragranceRepository.java similarity index 94% rename from src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopFragranceRepository.java rename to src/main/java/PerfumeOnMe/spring/workshop/repository/WorkshopFragranceRepository.java index 4d6c9f8..acecb93 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopFragranceRepository.java +++ b/src/main/java/PerfumeOnMe/spring/workshop/repository/WorkshopFragranceRepository.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.repository.workshop; +package PerfumeOnMe.spring.workshop.repository; import java.util.List; @@ -6,7 +6,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import PerfumeOnMe.spring.domain.WorkshopFragrance; +import PerfumeOnMe.spring.workshop.domain.WorkshopFragrance; public interface WorkshopFragranceRepository extends JpaRepository { diff --git a/src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopRepository.java b/src/main/java/PerfumeOnMe/spring/workshop/repository/WorkshopRepository.java similarity index 74% rename from src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopRepository.java rename to src/main/java/PerfumeOnMe/spring/workshop/repository/WorkshopRepository.java index 5eed63d..050cfb5 100644 --- a/src/main/java/PerfumeOnMe/spring/repository/workshop/WorkshopRepository.java +++ b/src/main/java/PerfumeOnMe/spring/workshop/repository/WorkshopRepository.java @@ -1,12 +1,12 @@ -package PerfumeOnMe.spring.repository.workshop; +package PerfumeOnMe.spring.workshop.repository; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; -import PerfumeOnMe.spring.domain.User; -import PerfumeOnMe.spring.domain.Workshop; +import PerfumeOnMe.spring.user.domain.User; +import PerfumeOnMe.spring.workshop.domain.Workshop; public interface WorkshopRepository extends JpaRepository { diff --git a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopRecommendationService.java b/src/main/java/PerfumeOnMe/spring/workshop/service/WorkshopRecommendationService.java similarity index 96% rename from src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopRecommendationService.java rename to src/main/java/PerfumeOnMe/spring/workshop/service/WorkshopRecommendationService.java index 24467b9..a9608c5 100644 --- a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopRecommendationService.java +++ b/src/main/java/PerfumeOnMe/spring/workshop/service/WorkshopRecommendationService.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service.workshop; +package PerfumeOnMe.spring.workshop.service; import java.util.ArrayList; import java.util.Arrays; @@ -9,9 +9,9 @@ import org.springframework.stereotype.Service; -import PerfumeOnMe.spring.domain.WorkshopFragrance; -import PerfumeOnMe.spring.repository.workshop.WorkshopFragranceRepository; -import PerfumeOnMe.spring.web.dto.workshop.WorkshopRequestDTO; +import PerfumeOnMe.spring.workshop.domain.WorkshopFragrance; +import PerfumeOnMe.spring.workshop.repository.WorkshopFragranceRepository; +import PerfumeOnMe.spring.workshop.web.dto.WorkshopRequestDTO; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopResult.java b/src/main/java/PerfumeOnMe/spring/workshop/service/WorkshopResult.java similarity index 95% rename from src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopResult.java rename to src/main/java/PerfumeOnMe/spring/workshop/service/WorkshopResult.java index 4ec29c6..a5cd79f 100644 --- a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopResult.java +++ b/src/main/java/PerfumeOnMe/spring/workshop/service/WorkshopResult.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service.workshop; +package PerfumeOnMe.spring.workshop.service; import lombok.AllArgsConstructor; import lombok.Builder; @@ -13,7 +13,7 @@ @NoArgsConstructor @AllArgsConstructor public class WorkshopResult { - + private String keywordSummary; // 시각적 키워드 (해시태그 형태) private String firstImpression; // 향기의 첫인상 (탑 노트 설명 + 사용자 성향) private String centerImpression; // 중심을 잡는 향 (미들 노트 설명 + 사용자 성향) diff --git a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopResultParser.java b/src/main/java/PerfumeOnMe/spring/workshop/service/WorkshopResultParser.java similarity index 99% rename from src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopResultParser.java rename to src/main/java/PerfumeOnMe/spring/workshop/service/WorkshopResultParser.java index ec09c5c..f6bd22b 100644 --- a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopResultParser.java +++ b/src/main/java/PerfumeOnMe/spring/workshop/service/WorkshopResultParser.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service.workshop; +package PerfumeOnMe.spring.workshop.service; import java.util.regex.Matcher; import java.util.regex.Pattern; diff --git a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java b/src/main/java/PerfumeOnMe/spring/workshop/service/WorkshopService.java similarity index 90% rename from src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java rename to src/main/java/PerfumeOnMe/spring/workshop/service/WorkshopService.java index b8179f5..3836ab9 100644 --- a/src/main/java/PerfumeOnMe/spring/service/workshop/WorkshopService.java +++ b/src/main/java/PerfumeOnMe/spring/workshop/service/WorkshopService.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.service.workshop; +package PerfumeOnMe.spring.workshop.service; import java.util.HashMap; import java.util.List; @@ -9,18 +9,18 @@ import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; import PerfumeOnMe.spring.apiPayload.exception.GeneralException; -import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; -import PerfumeOnMe.spring.converter.WorkshopConverter; -import PerfumeOnMe.spring.domain.User; -import PerfumeOnMe.spring.domain.Workshop; -import PerfumeOnMe.spring.domain.WorkshopFragrance; -import PerfumeOnMe.spring.repository.user.UserRepository; -import PerfumeOnMe.spring.repository.workshop.WorkshopRepository; -import PerfumeOnMe.spring.service.openAi.OpenAiService; -import PerfumeOnMe.spring.service.redis.WorkshopRedisService; -import PerfumeOnMe.spring.service.user.UserService; -import PerfumeOnMe.spring.web.dto.workshop.WorkshopRequestDTO; -import PerfumeOnMe.spring.web.dto.workshop.WorkshopResponseDTO; +import PerfumeOnMe.spring.external.openai.OpenAiService; +import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; +import PerfumeOnMe.spring.user.domain.User; +import PerfumeOnMe.spring.user.repository.UserRepository; +import PerfumeOnMe.spring.user.service.UserService; +import PerfumeOnMe.spring.workshop.converter.WorkshopConverter; +import PerfumeOnMe.spring.workshop.domain.Workshop; +import PerfumeOnMe.spring.workshop.domain.WorkshopFragrance; +import PerfumeOnMe.spring.workshop.redis.WorkshopRedisService; +import PerfumeOnMe.spring.workshop.repository.WorkshopRepository; +import PerfumeOnMe.spring.workshop.web.dto.WorkshopRequestDTO; +import PerfumeOnMe.spring.workshop.web.dto.WorkshopResponseDTO; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/workshop/ValidBaseNote.java b/src/main/java/PerfumeOnMe/spring/workshop/validation/annotation/ValidBaseNote.java similarity index 85% rename from src/main/java/PerfumeOnMe/spring/validation/annotation/workshop/ValidBaseNote.java rename to src/main/java/PerfumeOnMe/spring/workshop/validation/annotation/ValidBaseNote.java index 0dd1a65..3e2e701 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/annotation/workshop/ValidBaseNote.java +++ b/src/main/java/PerfumeOnMe/spring/workshop/validation/annotation/ValidBaseNote.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.validation.annotation.workshop; +package PerfumeOnMe.spring.workshop.validation.annotation; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; @@ -8,7 +8,7 @@ import com.nimbusds.jose.Payload; -import PerfumeOnMe.spring.validation.validator.workshop.ValidBaseNoteValidator; +import PerfumeOnMe.spring.workshop.validation.validator.ValidBaseNoteValidator; import jakarta.validation.Constraint; @Documented diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/workshop/ValidMiddleNote.java b/src/main/java/PerfumeOnMe/spring/workshop/validation/annotation/ValidMiddleNote.java similarity index 85% rename from src/main/java/PerfumeOnMe/spring/validation/annotation/workshop/ValidMiddleNote.java rename to src/main/java/PerfumeOnMe/spring/workshop/validation/annotation/ValidMiddleNote.java index f9291da..3ca8206 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/annotation/workshop/ValidMiddleNote.java +++ b/src/main/java/PerfumeOnMe/spring/workshop/validation/annotation/ValidMiddleNote.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.validation.annotation.workshop; +package PerfumeOnMe.spring.workshop.validation.annotation; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; @@ -6,7 +6,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import PerfumeOnMe.spring.validation.validator.workshop.ValidMiddleNoteValidator; +import PerfumeOnMe.spring.workshop.validation.validator.ValidMiddleNoteValidator; import jakarta.validation.Constraint; import jakarta.validation.Payload; diff --git a/src/main/java/PerfumeOnMe/spring/validation/annotation/workshop/ValidTopNote.java b/src/main/java/PerfumeOnMe/spring/workshop/validation/annotation/ValidTopNote.java similarity index 85% rename from src/main/java/PerfumeOnMe/spring/validation/annotation/workshop/ValidTopNote.java rename to src/main/java/PerfumeOnMe/spring/workshop/validation/annotation/ValidTopNote.java index 97d0153..e1eeafc 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/annotation/workshop/ValidTopNote.java +++ b/src/main/java/PerfumeOnMe/spring/workshop/validation/annotation/ValidTopNote.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.validation.annotation.workshop; +package PerfumeOnMe.spring.workshop.validation.annotation; import java.lang.annotation.Documented; import java.lang.annotation.ElementType; @@ -6,7 +6,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import PerfumeOnMe.spring.validation.validator.workshop.ValidTopNoteValidator; +import PerfumeOnMe.spring.workshop.validation.validator.ValidTopNoteValidator; import jakarta.validation.Constraint; import jakarta.validation.Payload; diff --git a/src/main/java/PerfumeOnMe/spring/validation/validator/workshop/ValidBaseNoteValidator.java b/src/main/java/PerfumeOnMe/spring/workshop/validation/validator/ValidBaseNoteValidator.java similarity index 82% rename from src/main/java/PerfumeOnMe/spring/validation/validator/workshop/ValidBaseNoteValidator.java rename to src/main/java/PerfumeOnMe/spring/workshop/validation/validator/ValidBaseNoteValidator.java index 56a1df6..ae5f784 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/validator/workshop/ValidBaseNoteValidator.java +++ b/src/main/java/PerfumeOnMe/spring/workshop/validation/validator/ValidBaseNoteValidator.java @@ -1,8 +1,8 @@ -package PerfumeOnMe.spring.validation.validator.workshop; +package PerfumeOnMe.spring.workshop.validation.validator; import java.util.Set; -import PerfumeOnMe.spring.validation.annotation.workshop.ValidBaseNote; +import PerfumeOnMe.spring.workshop.validation.annotation.ValidBaseNote; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; diff --git a/src/main/java/PerfumeOnMe/spring/validation/validator/workshop/ValidMiddleNoteValidator.java b/src/main/java/PerfumeOnMe/spring/workshop/validation/validator/ValidMiddleNoteValidator.java similarity index 83% rename from src/main/java/PerfumeOnMe/spring/validation/validator/workshop/ValidMiddleNoteValidator.java rename to src/main/java/PerfumeOnMe/spring/workshop/validation/validator/ValidMiddleNoteValidator.java index 3063c4c..0816bb7 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/validator/workshop/ValidMiddleNoteValidator.java +++ b/src/main/java/PerfumeOnMe/spring/workshop/validation/validator/ValidMiddleNoteValidator.java @@ -1,8 +1,8 @@ -package PerfumeOnMe.spring.validation.validator.workshop; +package PerfumeOnMe.spring.workshop.validation.validator; import java.util.Set; -import PerfumeOnMe.spring.validation.annotation.workshop.ValidMiddleNote; +import PerfumeOnMe.spring.workshop.validation.annotation.ValidMiddleNote; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; diff --git a/src/main/java/PerfumeOnMe/spring/validation/validator/workshop/ValidTopNoteValidator.java b/src/main/java/PerfumeOnMe/spring/workshop/validation/validator/ValidTopNoteValidator.java similarity index 84% rename from src/main/java/PerfumeOnMe/spring/validation/validator/workshop/ValidTopNoteValidator.java rename to src/main/java/PerfumeOnMe/spring/workshop/validation/validator/ValidTopNoteValidator.java index e6d5f4a..5e51f90 100644 --- a/src/main/java/PerfumeOnMe/spring/validation/validator/workshop/ValidTopNoteValidator.java +++ b/src/main/java/PerfumeOnMe/spring/workshop/validation/validator/ValidTopNoteValidator.java @@ -1,8 +1,8 @@ -package PerfumeOnMe.spring.validation.validator.workshop; +package PerfumeOnMe.spring.workshop.validation.validator; import java.util.Set; -import PerfumeOnMe.spring.validation.annotation.workshop.ValidTopNote; +import PerfumeOnMe.spring.workshop.validation.annotation.ValidTopNote; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; diff --git a/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java b/src/main/java/PerfumeOnMe/spring/workshop/web/controller/WorkshopController.java similarity index 86% rename from src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java rename to src/main/java/PerfumeOnMe/spring/workshop/web/controller/WorkshopController.java index d6eeb19..6e4e756 100644 --- a/src/main/java/PerfumeOnMe/spring/web/controller/WorkshopController.java +++ b/src/main/java/PerfumeOnMe/spring/workshop/web/controller/WorkshopController.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.controller; +package PerfumeOnMe.spring.workshop.web.controller; import java.util.List; @@ -12,11 +12,11 @@ import org.springframework.web.bind.annotation.RestController; import PerfumeOnMe.spring.apiPayload.ApiResponse; -import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; -import PerfumeOnMe.spring.service.workshop.WorkshopService; -import PerfumeOnMe.spring.web.docs.workshop.WorkshopControllerDocs; -import PerfumeOnMe.spring.web.dto.workshop.WorkshopRequestDTO; -import PerfumeOnMe.spring.web.dto.workshop.WorkshopResponseDTO; +import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; +import PerfumeOnMe.spring.workshop.service.WorkshopService; +import PerfumeOnMe.spring.workshop.web.docs.WorkshopControllerDocs; +import PerfumeOnMe.spring.workshop.web.dto.WorkshopRequestDTO; +import PerfumeOnMe.spring.workshop.web.dto.WorkshopResponseDTO; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/PerfumeOnMe/spring/web/docs/workshop/WorkshopControllerDocs.java b/src/main/java/PerfumeOnMe/spring/workshop/web/docs/WorkshopControllerDocs.java similarity index 96% rename from src/main/java/PerfumeOnMe/spring/web/docs/workshop/WorkshopControllerDocs.java rename to src/main/java/PerfumeOnMe/spring/workshop/web/docs/WorkshopControllerDocs.java index 62a3c52..a1c51cd 100644 --- a/src/main/java/PerfumeOnMe/spring/web/docs/workshop/WorkshopControllerDocs.java +++ b/src/main/java/PerfumeOnMe/spring/workshop/web/docs/WorkshopControllerDocs.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.docs.workshop; +package PerfumeOnMe.spring.workshop.web.docs; import java.util.List; @@ -8,9 +8,9 @@ import org.springframework.web.bind.annotation.RequestBody; import PerfumeOnMe.spring.apiPayload.ApiResponse; -import PerfumeOnMe.spring.config.security.auth.userDetails.CustomUserDetails; -import PerfumeOnMe.spring.web.dto.workshop.WorkshopRequestDTO; -import PerfumeOnMe.spring.web.dto.workshop.WorkshopResponseDTO; +import PerfumeOnMe.spring.security.auth.userDetails.CustomUserDetails; +import PerfumeOnMe.spring.workshop.web.dto.WorkshopRequestDTO; +import PerfumeOnMe.spring.workshop.web.dto.WorkshopResponseDTO; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopRequestDTO.java b/src/main/java/PerfumeOnMe/spring/workshop/web/dto/WorkshopRequestDTO.java similarity index 92% rename from src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopRequestDTO.java rename to src/main/java/PerfumeOnMe/spring/workshop/web/dto/WorkshopRequestDTO.java index 59797b3..15c7889 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopRequestDTO.java +++ b/src/main/java/PerfumeOnMe/spring/workshop/web/dto/WorkshopRequestDTO.java @@ -1,10 +1,10 @@ -package PerfumeOnMe.spring.web.dto.workshop; +package PerfumeOnMe.spring.workshop.web.dto; import java.util.Map; -import PerfumeOnMe.spring.validation.annotation.workshop.ValidBaseNote; -import PerfumeOnMe.spring.validation.annotation.workshop.ValidMiddleNote; -import PerfumeOnMe.spring.validation.annotation.workshop.ValidTopNote; +import PerfumeOnMe.spring.workshop.validation.annotation.ValidBaseNote; +import PerfumeOnMe.spring.workshop.validation.annotation.ValidMiddleNote; +import PerfumeOnMe.spring.workshop.validation.annotation.ValidTopNote; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; diff --git a/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java b/src/main/java/PerfumeOnMe/spring/workshop/web/dto/WorkshopResponseDTO.java similarity index 99% rename from src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java rename to src/main/java/PerfumeOnMe/spring/workshop/web/dto/WorkshopResponseDTO.java index 1c132ef..df9feff 100644 --- a/src/main/java/PerfumeOnMe/spring/web/dto/workshop/WorkshopResponseDTO.java +++ b/src/main/java/PerfumeOnMe/spring/workshop/web/dto/WorkshopResponseDTO.java @@ -1,4 +1,4 @@ -package PerfumeOnMe.spring.web.dto.workshop; +package PerfumeOnMe.spring.workshop.web.dto; import java.time.LocalDateTime; import java.util.List; From 4badcd53224563c76b45fc47a3091b36a58414e3 Mon Sep 17 00:00:00 2001 From: hcg0127 Date: Thu, 14 Aug 2025 19:29:11 +0900 Subject: [PATCH 323/339] [Docs] Update README.md --- README.md | 284 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 282 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index cab7da1..cf19be4 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,282 @@ -# Server -PerFumeOnMe/Server(Spring Boot) +