diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..4e616c9 Binary files /dev/null and b/.DS_Store differ 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/.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/.github/ISSUE_TEMPLATE/issue-template.md b/.github/ISSUE_TEMPLATE/issue-template.md new file mode 100644 index 0000000..d5a0378 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/issue-template.md @@ -0,0 +1,22 @@ +--- +name: Issue Template +about: 이슈 템플릿 +title: '' +labels: "✨ Feat" +assignees: '' + +--- + +## 📌 만들고자 하는 기능 + +(여기에 기능 설명을 작성하세요) + +## ✅ 구현 내용 + +- [ ] (구체적인 구현 항목을 작성하세요) +- [ ] (구체적인 구현 항목을 작성하세요) +- [ ] (구체적인 구현 항목을 작성하세요) + +## ⏰ 예상 기간 + +0월 00일 ~ 0월 00일 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..c5d6b0d --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,20 @@ +## 💡 관련 이슈 + +(관련있는 이슈 번호를 적어주세요.) + +## 📢 작업 내용 + +- (작업한 내용 작성) +- (작업한 내용 작성) + +## 🗨️ 리뷰 요구사항(선택) + +- (리뷰 요구사항 작성) + +## ✅ 체크리스트 + +- [ ] 코드가 정상적으로 컴파일되나요? +- [ ] 이슈 내용을 전부 구현했나요? +- [ ] 작업 기간 내에 개발을 완료했나요? +- [ ] 리뷰어를 선택했나요? +- [ ] 라벨을 지정했나요? diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml new file mode 100644 index 0000000..1ae41f3 --- /dev/null +++ b/.github/workflows/dev_deploy.yml @@ -0,0 +1,108 @@ +name: PerfumeonMe Dev CI/CD + +on: + push: + branches: [ "develop" ] +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'adopt' + + - name: gradlew mod modify + run: chmod +x gradlew + + # 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: Spring Boot Build + run: ./gradlew clean build --exclude-task test + + - name: Docker Image Build + run: docker build -t chanee29/perfumeonme . + + - name: docker login + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: docker Hub Push + run: docker push chanee29/perfumeonme + + - name: get GitHub IP + id: ip + uses: haythem/public-ip@v1.2 + + - 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: | + 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 & Deploy Spring (bind to loopback) + uses: appleboy/ssh-action@v0.1.6 + with: + host: ${{ secrets.EC2_HOST }} + username: ubuntu + key: ${{ secrets.EC2_SSH_PRIVATE_KEY }} + 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 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 }} \ + -e DB_PASSWORD=${{ secrets.ENV_DB_PASSWORD }} \ + -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 }} \ + -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 }} \ + -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 + + + - 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 new file mode 100644 index 0000000..2c46216 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +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/ + +### application-local.yml ### +src/main/resources/application-local.yml +src/main/resources/application-secret.yml \ No newline at end of file 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/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/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 diff --git a/README.md b/README.md index cab7da1..f28eddc 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,282 @@ -# Server -PerFumeOnMe/Server(Spring Boot) +
+ +# 🌸 퍼퓨온미 (Perfume On Me) + +**향수를 더 쉽고 즐겁게, 경험하다** +향기와 경험을 담아내는 새로운 방식의 **향수 추천·경험 플랫폼** + +퍼퓨온미 커버 이미지 + +
+ +--- + +## 📌 프로젝트 소개 + +퍼퓨온미는 사용자가 자신에게 어울리는 향수를 쉽고 재미있게 찾을 수 있도록 돕는 향수 추천·경험 플랫폼입니다. +GPT 기반 분석, 키워드 검색, 설문 등 다양한 방법을 통해 사용자의 취향을 파악하고, +성격·기분·스타일에 맞춘 개인 맞춤형 향수 추천을 제공합니다. +이를 통해 단순한 제품 구매를 넘어, 향수를 통해 추억과 감정을 담아내는 새로운 경험을 제안합니다. + +--- + +## 🌱 프로젝트 배경 + +수천 가지 향수가 존재하지만, 대부분의 사람들은 어떤 향이 자신에게 어울릴지 몰라 선택에 어려움을 겪습니다. +또한 향에 대한 취향은 언어로 설명하기 어려워 기존의 검색·추천 방식에는 한계가 있습니다. +퍼퓨온미는 이러한 문제를 해결하고자, 다양한 접근 방식과 개인화 추천을 결합한 플랫폼을 만들었습니다. +향수를 비싸고 어려운 액세서리가 아닌, 누구나 즐길 수 있는 일상의 취미로 바꾸는 것이 우리의 목표입니다. + +--- + +## 🔗 배포 주소 + +> [🌐 퍼퓨온미 바로가기](https://perfumeonme.vercel.app) + +--- + +## ✨ 주요 기능 + +- 💡 **취향 맞춤 추천** : 취향 기반 개인 맞춤 향수 추천 +- 📚 **향수 아카이브** : 성별, 상황, 계절, 가격, 노트별 등 검색 및 필터 +- 🧾 **시향 기록** : 향에 대한 개인 다이어리 기록 +- 📱 **추천 컨텐츠** : 이미지 기반, 온라인 공방, 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 + +--- + diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..292a593 --- /dev/null +++ b/build.gradle @@ -0,0 +1,97 @@ +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 + } +} + +repositories { + mavenCentral() +} + +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: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" + + // 테스트 관련 의존성 + 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' + // 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' + // 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 + implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.0' + // Apache POI + implementation 'org.apache.poi:poi-ooxml:5.2.3' + // WebClient + 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') { + useJUnitPlatform() +} + +clean { + delete file('src/main/generated') +} + +jar { + enabled = false +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ff23a68 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/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/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000..fc18281 Binary files /dev/null and b/src/.DS_Store differ diff --git a/src/main/.DS_Store b/src/main/.DS_Store new file mode 100644 index 0000000..fe4ae2f Binary files /dev/null and b/src/main/.DS_Store differ diff --git a/src/main/java/.DS_Store b/src/main/java/.DS_Store new file mode 100644 index 0000000..525eba5 Binary files /dev/null and b/src/main/java/.DS_Store differ diff --git a/src/main/java/PerfumeOnMe/.DS_Store b/src/main/java/PerfumeOnMe/.DS_Store new file mode 100644 index 0000000..45121ee Binary files /dev/null and b/src/main/java/PerfumeOnMe/.DS_Store differ diff --git a/src/main/java/PerfumeOnMe/spring/Application.java b/src/main/java/PerfumeOnMe/spring/Application.java new file mode 100644 index 0000000..7d5ece8 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/Application.java @@ -0,0 +1,17 @@ +package PerfumeOnMe.spring; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@SpringBootApplication +@EnableJpaAuditing +@EnableJpaRepositories +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..52f7af2 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/ApiResponse.java @@ -0,0 +1,60 @@ +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; + +@Getter +@AllArgsConstructor +@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; + private final String message; + @JsonInclude(JsonInclude.Include.NON_NULL) + private T result; + + // 200 OK + public static ApiResponse onSuccess(T result) { + return new ApiResponse<>(true, SuccessStatus._OK.getCode(), SuccessStatus._OK.getMessage(), result); + } + + // 201 CREATED, ... + public static ApiResponse of(BaseCode code, T result) { + return new ApiResponse<>(true, code.getReasonHttpStatus().getCode(), code.getReasonHttpStatus().getMessage(), + result); + } + + // 400 CLIENT, 500 SERVER 등 실패한 경우 응답 생성 + 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 { + + // 응답 헤더 작성 + 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)); + } +} 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..64e1ee0 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/ErrorStatus.java @@ -0,0 +1,136 @@ +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; + +@Getter +@AllArgsConstructor +public enum ErrorStatus implements BaseErrorCode { + + //일반적인 에러 + _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_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", "로그인 중 알 수 없는 오류가 발생했습니다."), + NICKNAME_DUPLICATE(HttpStatus.BAD_REQUEST, "MEMBER4006", "이미 사용된 닉네임입니다."), + USER_ID_NULL(HttpStatus.UNAUTHORIZED, "MEMBER4007", "유저 정보가 존재 하지 않습니다. "), + + // 토큰 에러 + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "TOKEN4001", "유효하지 않은 토큰입니다."), + REFRESH_TOKEN_NOT_FOUND(HttpStatus.UNAUTHORIZED, "TOKEN4002", "리프레시 토큰을 입력해야 합니다."), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "TOKEN4003", "만료된 토큰입니다."), + LOGOUT_ACCESS_TOKEN(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", "토큰이 없습니다."), + + // 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", "지원하지 않는 향수타입입니다."), + PRICE_PARSING_ERROR(HttpStatus.BAD_REQUEST, "DATA4003", "가격 정보를 숫자로 변환할 수 없습니다."), + + // 향수 상세 페이지 에러 + FRAGRANCE_NOT_FOUND(HttpStatus.BAD_REQUEST, "FRAGRANCE4001", "해당 ID에 해당하는 향수를 찾을 수 없습니다."), + + // 향수 즐겨찾기 에러 + 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", "유효하지 않은 향수 타입입니다."), + 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", "가격 범위가 올바르지 않습니다."), + + // 챗봇 에러 + 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 요청 한도를 초과했습니다. 잠시 후 다시 시도해주세요."), + + // FastAPI 연동 에러 + FASTAPI_COMMUNICATION_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "FASTAPI5001", "FastAPI 서버 통신 중 오류가 발생했습니다."), + + //JSON 파싱 에러 + JSON_PARSE_ERROR(HttpStatus.BAD_REQUEST, "JSON4001", "JSON 파싱에 실패했습니다."), + + // + + // 다이어리 에러 + 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", "해당 월에 작성된 다이어리가 없습니다."), + + // 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에서 만료되었거나 저장되어 있지 않습니다."), + 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", "지원하지 않은 파일 확장자입니다."), + + // 이미지 키워드 에러 + 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", "향수공방 미리보기 결과가 만료되었습니다. 다시 시도해주세요."), + WORKSHOP_NAME_DUPLICATE(HttpStatus.BAD_REQUEST, "WORKSHOP4003", "이미 같은 이름으로 저장된 향수공방 결과가 있습니다."), + WORKSHOP_USER_NOT_MATCH(HttpStatus.BAD_REQUEST, "WORKSHOP4004", "해당 향수공방 결과에 접근할 수 없습니다."), + WORKSHOP_ID_NULL(HttpStatus.UNAUTHORIZED, "WORKSHOP4005", "해당 향수공방 결과 정보가 존재하지 않습니다."), + + // 예시,,, + 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..6668efb --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/code/status/SuccessStatus.java @@ -0,0 +1,44 @@ +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; + +@Getter +@AllArgsConstructor +public enum SuccessStatus implements BaseCode { + + // 일반적인 응답 + _OK(HttpStatus.OK, "COMMON200", "성공입니다."), + _CREATED(HttpStatus.OK, "COMMON201", "리소스를 성공적으로 생성했습니다."), + DIARY_UPDATED(HttpStatus.OK, "DIARY200", "다이어리가 수정되었습니다."), + DIARY_DELETED(HttpStatus.OK, "DIARY201", "다이어리가 삭제되었습니다."); + + 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..e0495ec --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/exception/ExceptionAdvice.java @@ -0,0 +1,126 @@ +package PerfumeOnMe.spring.apiPayload.exception; + +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; +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 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._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 + ); + } +} 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..1b95dfa --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/apiPayload/exception/GeneralException.java @@ -0,0 +1,34 @@ +package PerfumeOnMe.spring.apiPayload.exception; + +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; + +@Getter +@AllArgsConstructor +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; + } + return null; + } + + public ErrorReasonDTO getErrorReason() { + return this.code.getReason(); + } + + public ErrorReasonDTO getErrorReasonHttpStatus() { + return this.code.getReasonHttpStatus(); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/chatbot/converter/ChatConverter.java b/src/main/java/PerfumeOnMe/spring/chatbot/converter/ChatConverter.java new file mode 100644 index 0000000..d97b3b2 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/chatbot/converter/ChatConverter.java @@ -0,0 +1,25 @@ +package PerfumeOnMe.spring.chatbot.converter; + +import java.util.List; +import java.util.stream.Collectors; + +import PerfumeOnMe.spring.chatbot.domain.ChatMessage; +import PerfumeOnMe.spring.chatbot.web.dto.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()); + } +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/chatbot/domain/ChatMessage.java b/src/main/java/PerfumeOnMe/spring/chatbot/domain/ChatMessage.java new file mode 100644 index 0000000..984552f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/chatbot/domain/ChatMessage.java @@ -0,0 +1,51 @@ +package PerfumeOnMe.spring.chatbot.domain; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.CreationTimestamp; +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; +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 = "chat_messages") +public class ChatMessage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(columnDefinition = "TEXT", nullable = false) + private String userMessage; + + @Column(columnDefinition = "TEXT", nullable = false) + private String botResponse; + + @CreationTimestamp + private LocalDateTime createdAt; +} diff --git a/src/main/java/PerfumeOnMe/spring/chatbot/repository/ChatMessageRepository.java b/src/main/java/PerfumeOnMe/spring/chatbot/repository/ChatMessageRepository.java new file mode 100644 index 0000000..42e8b7c --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/chatbot/repository/ChatMessageRepository.java @@ -0,0 +1,17 @@ +package PerfumeOnMe.spring.chatbot.repository; + +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.chatbot.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/chatbot/service/ChatbotService.java b/src/main/java/PerfumeOnMe/spring/chatbot/service/ChatbotService.java new file mode 100644 index 0000000..8e027ad --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/chatbot/service/ChatbotService.java @@ -0,0 +1,13 @@ +package PerfumeOnMe.spring.chatbot.service; + +import PerfumeOnMe.spring.chatbot.web.dto.ChatBotRequestDTO; +import PerfumeOnMe.spring.chatbot.web.dto.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/chatbot/service/ChatbotServiceImpl.java b/src/main/java/PerfumeOnMe/spring/chatbot/service/ChatbotServiceImpl.java new file mode 100644 index 0000000..ffa363c --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/chatbot/service/ChatbotServiceImpl.java @@ -0,0 +1,123 @@ +package PerfumeOnMe.spring.chatbot.service; + +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.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; + +@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 + + private String systemPrompt; // ← 캐시된 프롬프트 + + @PostConstruct + public void init() { + this.systemPrompt = promptLoader.loadDefaultPrompt(); // 프롬프트 파일 로딩 + } + + /** + * 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); + } + 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() + .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 -> { + // 사용자의 질문과 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/chatbot/service/PromptLoader.java b/src/main/java/PerfumeOnMe/spring/chatbot/service/PromptLoader.java new file mode 100644 index 0000000..da1847a --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/chatbot/service/PromptLoader.java @@ -0,0 +1,26 @@ +package PerfumeOnMe.spring.chatbot.service; + +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); // 프롬프트 로딩 실패 + } + } +} diff --git a/src/main/java/PerfumeOnMe/spring/chatbot/web/controller/ChatbotController.java b/src/main/java/PerfumeOnMe/spring/chatbot/web/controller/ChatbotController.java new file mode 100644 index 0000000..314fb27 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/chatbot/web/controller/ChatbotController.java @@ -0,0 +1,49 @@ +package PerfumeOnMe.spring.chatbot.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.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 jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import reactor.core.publisher.Mono; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/chatbot") +public class ChatbotController implements ChatbotControllerDocs { + private final ChatbotService chatbotService; + + // 로그인한 사용자가 챗봇에게 질문을 보내고, + // OpenAI 로부터 받은 응답을 클라이언트에게 반환하는 API + @PostMapping + 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") + 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)); + } +} 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..008057f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/chatbot/web/docs/ChatbotControllerDocs.java @@ -0,0 +1,40 @@ +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.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.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 입니다." + ) + 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/chatbot/web/dto/ChatBotRequestDTO.java b/src/main/java/PerfumeOnMe/spring/chatbot/web/dto/ChatBotRequestDTO.java new file mode 100644 index 0000000..5da8e79 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/chatbot/web/dto/ChatBotRequestDTO.java @@ -0,0 +1,30 @@ +package PerfumeOnMe.spring.chatbot.web.dto; + +import PerfumeOnMe.spring.common.validation.annotation.ValidPage; +import PerfumeOnMe.spring.common.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/chatbot/web/dto/ChatBotResponseDTO.java b/src/main/java/PerfumeOnMe/spring/chatbot/web/dto/ChatBotResponseDTO.java new file mode 100644 index 0000000..c5fef91 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/chatbot/web/dto/ChatBotResponseDTO.java @@ -0,0 +1,32 @@ +package PerfumeOnMe.spring.chatbot.web.dto; + +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/chatbot/web/dto/ChatCompletionMessage.java b/src/main/java/PerfumeOnMe/spring/chatbot/web/dto/ChatCompletionMessage.java new file mode 100644 index 0000000..cd3ac3e --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/chatbot/web/dto/ChatCompletionMessage.java @@ -0,0 +1,19 @@ +package PerfumeOnMe.spring.chatbot.web.dto; + +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/chatbot/web/dto/ChatCompletionRequest.java b/src/main/java/PerfumeOnMe/spring/chatbot/web/dto/ChatCompletionRequest.java new file mode 100644 index 0000000..2f7caa5 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/chatbot/web/dto/ChatCompletionRequest.java @@ -0,0 +1,19 @@ +package PerfumeOnMe.spring.chatbot.web.dto; + +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 diff --git a/src/main/java/PerfumeOnMe/spring/common/base/BaseEntity.java b/src/main/java/PerfumeOnMe/spring/common/base/BaseEntity.java new file mode 100644 index 0000000..993c87c --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/base/BaseEntity.java @@ -0,0 +1,23 @@ +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; + +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/common/config/AmazonConfig.java b/src/main/java/PerfumeOnMe/spring/common/config/AmazonConfig.java new file mode 100644 index 0000000..48a3efb --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/config/AmazonConfig.java @@ -0,0 +1,56 @@ +package PerfumeOnMe.spring.common.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); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/common/config/OpenAIConfig.java b/src/main/java/PerfumeOnMe/spring/common/config/OpenAIConfig.java new file mode 100644 index 0000000..8cd92b4 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/config/OpenAIConfig.java @@ -0,0 +1,22 @@ +package PerfumeOnMe.spring.common.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 diff --git a/src/main/java/PerfumeOnMe/spring/common/config/QueryDSLConfig.java b/src/main/java/PerfumeOnMe/spring/common/config/QueryDSLConfig.java new file mode 100644 index 0000000..df1c1a8 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/config/QueryDSLConfig.java @@ -0,0 +1,21 @@ +package PerfumeOnMe.spring.common.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/common/config/RedisConfig.java b/src/main/java/PerfumeOnMe/spring/common/config/RedisConfig.java new file mode 100644 index 0000000..42eefa8 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/config/RedisConfig.java @@ -0,0 +1,27 @@ +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.core.StringRedisTemplate; + +@Configuration +public class RedisConfig { + + /* + RedisConnectionFactory의 구현체로 LettuceConnectionFactory 사용 및 빈 등록 + Spring Boot 자동 설정을 사용하기 위해 주석 처리 + */ + // @Bean + // public RedisConnectionFactory redisConnectionFactory() { + // return new LettuceConnectionFactory(); + // } + + /* + Key-Value를 String-String으로 저장하는 StringRedisTemplate 빈 등록 + */ + @Bean + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) { + return new StringRedisTemplate(redisConnectionFactory); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/common/config/RestTemplateConfig.java b/src/main/java/PerfumeOnMe/spring/common/config/RestTemplateConfig.java new file mode 100644 index 0000000..cae09ec --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/config/RestTemplateConfig.java @@ -0,0 +1,34 @@ +package PerfumeOnMe.spring.common.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; + } +} 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/common/config/WebClientConfig.java b/src/main/java/PerfumeOnMe/spring/common/config/WebClientConfig.java new file mode 100644 index 0000000..3b4130a --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/config/WebClientConfig.java @@ -0,0 +1,14 @@ +package PerfumeOnMe.spring.common.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/common/config/properties/ImageKeywordProperties.java b/src/main/java/PerfumeOnMe/spring/common/config/properties/ImageKeywordProperties.java new file mode 100644 index 0000000..8464265 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/config/properties/ImageKeywordProperties.java @@ -0,0 +1,40 @@ +package PerfumeOnMe.spring.common.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 diff --git a/src/main/java/PerfumeOnMe/spring/common/controller/RootController.java b/src/main/java/PerfumeOnMe/spring/common/controller/RootController.java new file mode 100644 index 0000000..cb9e2ae --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/controller/RootController.java @@ -0,0 +1,13 @@ +package PerfumeOnMe.spring.common.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"; + } +} 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/common/enums/Ambience.java b/src/main/java/PerfumeOnMe/spring/common/enums/Ambience.java new file mode 100644 index 0000000..66eb5b8 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/enums/Ambience.java @@ -0,0 +1,32 @@ +package PerfumeOnMe.spring.common.enums; + +import lombok.Getter; + +@Getter +public enum Ambience { + SOPHISTICATED("세련된"), + CUTE("귀여운"), + CALM("차분한"), + MATURE("성숙한"), + LOVELY("러블리한"), + ELEGANT("시크한"), + FRESH("신비로운"), + BRIGHT("밝은"), + LIVELY("몽환적인"), + GRACEFUL("우아한"); + + private final String displayName; + + 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/common/enums/Brand.java b/src/main/java/PerfumeOnMe/spring/common/enums/Brand.java new file mode 100644 index 0000000..93437a0 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/enums/Brand.java @@ -0,0 +1,41 @@ +package PerfumeOnMe.spring.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum Brand { + LOIVIE("로이비 (LOIVIE)"), + DIPTYQUE("딥티크 (DIPTYQUE)"), + JOMALONE("조 말론 (JOMALONE)"), + MAISON_MARGIELA("메종 마르지엘라 (MAISON MARGIELA)"), + 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)"), + 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/enums/FragranceGender.java b/src/main/java/PerfumeOnMe/spring/common/enums/FragranceGender.java new file mode 100644 index 0000000..1f59cf5 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/enums/FragranceGender.java @@ -0,0 +1,12 @@ +package PerfumeOnMe.spring.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum FragranceGender { + MALE("남성용"), FEMALE("여성용"), NEUTRAL("남녀불문"); + + private final String koName; // 한국어 변환 +} diff --git a/src/main/java/PerfumeOnMe/spring/common/enums/FragranceType.java b/src/main/java/PerfumeOnMe/spring/common/enums/FragranceType.java new file mode 100644 index 0000000..e776c32 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/enums/FragranceType.java @@ -0,0 +1,21 @@ +package PerfumeOnMe.spring.common.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum FragranceType { + + 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 int diffusionRange; // 퍼짐 범위 + +} + diff --git a/src/main/java/PerfumeOnMe/spring/common/enums/Gender.java b/src/main/java/PerfumeOnMe/spring/common/enums/Gender.java new file mode 100644 index 0000000..554133a --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/enums/Gender.java @@ -0,0 +1,25 @@ +package PerfumeOnMe.spring.common.enums; + +import lombok.Getter; + +@Getter +public enum Gender { + FEMININE("여성적인"), + MASCULINE("남성적인"), + NEUTRAL("중성적인"); + + private final String displayName; + + 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/common/enums/KeywordCategory.java b/src/main/java/PerfumeOnMe/spring/common/enums/KeywordCategory.java new file mode 100644 index 0000000..f8a73f0 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/enums/KeywordCategory.java @@ -0,0 +1,5 @@ +package PerfumeOnMe.spring.common.enums; + +public enum KeywordCategory { + AMBIENCE, STYLE, GENDER, SEASON, PERSONALITY +} diff --git a/src/main/java/PerfumeOnMe/spring/common/enums/NoteType.java b/src/main/java/PerfumeOnMe/spring/common/enums/NoteType.java new file mode 100644 index 0000000..8478b5b --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/enums/NoteType.java @@ -0,0 +1,5 @@ +package PerfumeOnMe.spring.common.enums; + +public enum NoteType { + TOP, MIDDLE, BASE +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/common/enums/Personality.java b/src/main/java/PerfumeOnMe/spring/common/enums/Personality.java new file mode 100644 index 0000000..c2410ab --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/enums/Personality.java @@ -0,0 +1,32 @@ +package PerfumeOnMe.spring.common.enums; + +import lombok.Getter; + +@Getter +public enum Personality { + QUIET("조용한"), + LOGICAL("논리적인"), + STRONG("개성강한"), + CHARISMATIC("카리스마 있는"), + CAUTIOUS("신중한"), + LIVELY("활발한"), + WARM("따뜻한"), + EMOTIONAL("감성적인"), + FRIENDLY("친근한"), + PASSIONATE("쾌활한"); + + private final String displayName; + + 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/common/enums/Season.java b/src/main/java/PerfumeOnMe/spring/common/enums/Season.java new file mode 100644 index 0000000..655b07d --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/enums/Season.java @@ -0,0 +1,26 @@ +package PerfumeOnMe.spring.common.enums; + +import lombok.Getter; + +@Getter +public enum Season { + SPRING("봄"), + SUMMER("여름"), + AUTUMN("가을"), + WINTER("겨울"); + + private final String displayName; + + 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/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/common/enums/Style.java b/src/main/java/PerfumeOnMe/spring/common/enums/Style.java new file mode 100644 index 0000000..943a3eb --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/enums/Style.java @@ -0,0 +1,32 @@ +package PerfumeOnMe.spring.common.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; + } + + 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); + } +} 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/common/fragranceInit/FragranceImportService.java b/src/main/java/PerfumeOnMe/spring/common/fragranceInit/FragranceImportService.java new file mode 100644 index 0000000..c37d175 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/fragranceInit/FragranceImportService.java @@ -0,0 +1,139 @@ +package PerfumeOnMe.spring.common.fragranceInit; + +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.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +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; + +/** + * 엑셀로부터 향수 정보를 불러와 DB에 저장하는 서비스 클래스 + * - 향수 정보는 "향수정보" 시트에서 가져오며, FragranceRowProcessor를 통해 처리 + * - 가격 정보는 "가격" 시트에서 직접 처리하여 저장 + * - @PostConstruct 어노테이션을 통해 서버 구동 시 자동 실행 + */ + +@Service +@RequiredArgsConstructor +@Slf4j +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); + log.info("✅향수 엑셀 데이터 로드 완료!"); + } + + /** + * 엑셀 파일 시트를 순회하며 향수 정보와 가격 정보를 각각 처리 + * 엑셀 파일에서 밑에 있는 향수정보 시트와 가격 시트를 각각 가져옴 + */ + 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 -> { + // 동일한 ml, 가격이 이미 존재하는지 확인 + Price savedPrice = priceRepository.findByMlCountAndPrice(mlCount, price) + .orElseGet(() -> priceRepository.save( + Price.builder() + .mlCount(mlCount) + .price(price) + .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, "가격 파싱 실패: " + priceStr); // 파싱 실패 로그 + } + } + + /** + * 셀에서 문자열 값을 안전하게 가져오는 유틸 메서드 + * 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/common/fragranceInit/FragranceRowProcessor.java b/src/main/java/PerfumeOnMe/spring/common/fragranceInit/FragranceRowProcessor.java new file mode 100644 index 0000000..a240cc1 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/fragranceInit/FragranceRowProcessor.java @@ -0,0 +1,267 @@ +package PerfumeOnMe.spring.common.fragranceInit; + +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.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +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; + +@Service +@RequiredArgsConstructor +@Slf4j +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()) { + log.info("⚠️이미 존재하는 향수: " + 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; + 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 "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); + }; + } + + /* + 엑셀 시트에 남성용, 여성용, 남녀불문 이라고 저장되어 있기 때문에 + 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 -> throw new GeneralException(ErrorStatus.UNSUPPORTED_TYPE); + }; + } +} diff --git a/src/main/java/PerfumeOnMe/spring/common/util/CharacterImageMapper.java b/src/main/java/PerfumeOnMe/spring/common/util/CharacterImageMapper.java new file mode 100644 index 0000000..14a6738 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/util/CharacterImageMapper.java @@ -0,0 +1,56 @@ +package PerfumeOnMe.spring.common.util; + +import java.util.Map; + +import PerfumeOnMe.spring.common.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/common/util/EnumDisplayNameMapper.java b/src/main/java/PerfumeOnMe/spring/common/util/EnumDisplayNameMapper.java new file mode 100644 index 0000000..8a76ff1 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/util/EnumDisplayNameMapper.java @@ -0,0 +1,32 @@ +package PerfumeOnMe.spring.common.util; + +import java.util.List; + +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 { + + 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/common/util/JsonUtils.java b/src/main/java/PerfumeOnMe/spring/common/util/JsonUtils.java new file mode 100644 index 0000000..d85a418 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/util/JsonUtils.java @@ -0,0 +1,17 @@ +package PerfumeOnMe.spring.common.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); + } + } +} diff --git a/src/main/java/PerfumeOnMe/spring/common/validation/annotation/ValidPage.java b/src/main/java/PerfumeOnMe/spring/common/validation/annotation/ValidPage.java new file mode 100644 index 0000000..94c511e --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/validation/annotation/ValidPage.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.common.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.common.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/common/validation/annotation/ValidSize.java b/src/main/java/PerfumeOnMe/spring/common/validation/annotation/ValidSize.java new file mode 100644 index 0000000..5ac7e53 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/validation/annotation/ValidSize.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.common.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.common.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/common/validation/validator/PageValidator.java b/src/main/java/PerfumeOnMe/spring/common/validation/validator/PageValidator.java new file mode 100644 index 0000000..9ad9223 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/validation/validator/PageValidator.java @@ -0,0 +1,13 @@ +package PerfumeOnMe.spring.common.validation.validator; + +import PerfumeOnMe.spring.common.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/common/validation/validator/SizeValidator.java b/src/main/java/PerfumeOnMe/spring/common/validation/validator/SizeValidator.java new file mode 100644 index 0000000..37bb690 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/common/validation/validator/SizeValidator.java @@ -0,0 +1,13 @@ +package PerfumeOnMe.spring.common.validation.validator; + +import PerfumeOnMe.spring.common.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 diff --git a/src/main/java/PerfumeOnMe/spring/diary/converter/DiaryConverter.java b/src/main/java/PerfumeOnMe/spring/diary/converter/DiaryConverter.java new file mode 100644 index 0000000..a3c6823 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/diary/converter/DiaryConverter.java @@ -0,0 +1,15 @@ +package PerfumeOnMe.spring.diary.converter; + +import PerfumeOnMe.spring.diary.web.dto.DiaryResponseDTO; +import PerfumeOnMe.spring.diary.domain.Diary; + +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/diary/domain/Diary.java b/src/main/java/PerfumeOnMe/spring/diary/domain/Diary.java new file mode 100644 index 0000000..d9e451f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/diary/domain/Diary.java @@ -0,0 +1,60 @@ +package PerfumeOnMe.spring.diary.domain; + +import java.time.LocalDate; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import PerfumeOnMe.spring.common.base.BaseEntity; +import PerfumeOnMe.spring.user.domain.User; +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 = "diaries") +public class Diary extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @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; + + // 다이어리 수정하는 메서드 + public void updateFragranceNameAndContent(String fragranceName, String content) { + this.fragranceName = fragranceName; + this.content = content; + } +} diff --git a/src/main/java/PerfumeOnMe/spring/diary/repository/DiaryRepository.java b/src/main/java/PerfumeOnMe/spring/diary/repository/DiaryRepository.java new file mode 100644 index 0000000..4d2efed --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/diary/repository/DiaryRepository.java @@ -0,0 +1,18 @@ +package PerfumeOnMe.spring.diary.repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.diary.domain.Diary; + +public interface DiaryRepository extends JpaRepository, DiaryRepositoryCustom { + + 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/diary/repository/DiaryRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/diary/repository/DiaryRepositoryCustom.java new file mode 100644 index 0000000..94e2afe --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/diary/repository/DiaryRepositoryCustom.java @@ -0,0 +1,5 @@ +package PerfumeOnMe.spring.diary.repository; + +public interface DiaryRepositoryCustom { + +} diff --git a/src/main/java/PerfumeOnMe/spring/diary/repository/DiaryRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/diary/repository/DiaryRepositoryImpl.java new file mode 100644 index 0000000..73518cd --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/diary/repository/DiaryRepositoryImpl.java @@ -0,0 +1,11 @@ +package PerfumeOnMe.spring.diary.repository; + +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class DiaryRepositoryImpl implements DiaryRepositoryCustom { + +} diff --git a/src/main/java/PerfumeOnMe/spring/diary/service/DiaryService.java b/src/main/java/PerfumeOnMe/spring/diary/service/DiaryService.java new file mode 100644 index 0000000..50d4936 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/diary/service/DiaryService.java @@ -0,0 +1,26 @@ +package PerfumeOnMe.spring.diary.service; + +import java.time.LocalDate; +import java.util.List; + +import PerfumeOnMe.spring.diary.web.dto.DiaryRequestDTO; +import PerfumeOnMe.spring.diary.web.dto.DiaryResponseDTO; + +public interface DiaryService { + + // 다이어리 추가 API + DiaryResponseDTO.AddDiaryResponse addDiary(Long userId, DiaryRequestDTO.AddDiaryRequest addDiaryRequest); + + // 다이어리 수정 API + void updateDiary(Long userId, Long diaryId, DiaryRequestDTO.UpdateDiaryRequest updateDiaryRequest); + + // 다이어리 삭제 API + void deleteDiary(Long userId, Long diaryId); + + // 일별 다이어리 상세 조회 API + List searchDailyDiary(Long userId, LocalDate date); + + // 월별 다이어리 조회 API + List searchMonthlyDiary(Long userId, LocalDate startDate, + LocalDate endDate); +} diff --git a/src/main/java/PerfumeOnMe/spring/diary/service/DiaryServiceImpl.java b/src/main/java/PerfumeOnMe/spring/diary/service/DiaryServiceImpl.java new file mode 100644 index 0000000..85f8fda --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/diary/service/DiaryServiceImpl.java @@ -0,0 +1,106 @@ +package PerfumeOnMe.spring.diary.service; + +import java.time.LocalDate; +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.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 +@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); + } + + // 다이어리 수정 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); + } + + // 다이어리 삭제 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); + } + + // 일별 다이어리 상세 조회 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); + } + + // 월별 다이어리 조회 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/diary/web/controller/DiaryController.java b/src/main/java/PerfumeOnMe/spring/diary/web/controller/DiaryController.java new file mode 100644 index 0000000..b10cebe --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/diary/web/controller/DiaryController.java @@ -0,0 +1,86 @@ +package PerfumeOnMe.spring.diary.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; +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.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 jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/diary") +public class DiaryController implements DiaryControllerDocs { + + private final DiaryService diaryService; + + // 다이어리 추가 API + @PostMapping("/write") + 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)); + } + + // 다이어리 수정 API + @PatchMapping("/{diaryId}/update") + 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)); + } + + // 다이어리 삭제 API + @DeleteMapping("/{diaryId}/delete") + public ResponseEntity> deleteDiary( + @PathVariable Long diaryId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + diaryService.deleteDiary(userDetails.getUserId(), diaryId); + return ResponseEntity.ok(ApiResponse.of(SuccessStatus.DIARY_DELETED, null)); + } + + // 일별 다이어리 상세 조회 API + @GetMapping("/daily/{date}") + public ResponseEntity>> searchDailyDiary( + @PathVariable LocalDate date, + @AuthenticationPrincipal CustomUserDetails userDetails) { + List result = diaryService.searchDailyDiary(userDetails.getUserId(), + date); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } + + // 월별 다이어리 조회 API + @GetMapping("/monthly/{year}/{month}") + 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/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/diary/web/dto/DiaryRequestDTO.java b/src/main/java/PerfumeOnMe/spring/diary/web/dto/DiaryRequestDTO.java new file mode 100644 index 0000000..4bd4b81 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/diary/web/dto/DiaryRequestDTO.java @@ -0,0 +1,27 @@ +package PerfumeOnMe.spring.diary.web.dto; + +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; + } + + // 다이어리 수정 요청 DTO + @Getter + @Setter + public static class UpdateDiaryRequest { + private String fragranceName; + private String content; + } + +} diff --git a/src/main/java/PerfumeOnMe/spring/diary/web/dto/DiaryResponseDTO.java b/src/main/java/PerfumeOnMe/spring/diary/web/dto/DiaryResponseDTO.java new file mode 100644 index 0000000..c241a1d --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/diary/web/dto/DiaryResponseDTO.java @@ -0,0 +1,75 @@ +package PerfumeOnMe.spring.diary.web.dto; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import PerfumeOnMe.spring.diary.domain.Diary; +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; + } + + // 일별 다이어리 상세 조회 응답 DTO + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class SearchDailyDiaryResponse { + private Long id; + private String fragranceName; + 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()) + .fragranceName(diary.getFragranceName()) + .date(diary.getDate()) + .content(diary.getContent()) + .createdAt(diary.getCreatedAt()) + .updatedAt(diary.getUpdatedAt()) + .build()) + .toList(); + } + } + + // 월별 다이어리 조회 응답 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(); + } + } + +} diff --git a/src/main/java/PerfumeOnMe/spring/external/fastapi/FastApiClient.java b/src/main/java/PerfumeOnMe/spring/external/fastapi/FastApiClient.java new file mode 100644 index 0000000..24f15cc --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/external/fastapi/FastApiClient.java @@ -0,0 +1,65 @@ +package PerfumeOnMe.spring.external.fastapi; + +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.external.fastapi.dto.FastApiPbtiRecommendResponse; +import PerfumeOnMe.spring.external.fastapi.dto.FastApiRecommendRequest; +import PerfumeOnMe.spring.external.fastapi.dto.FastApiRecommendResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class FastApiClient { + + private final RestTemplate restTemplate; + + @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(); + 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); + } + } + + public FastApiPbtiRecommendResponse getFullPbtiResult(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); + } + } + +} diff --git a/src/main/java/PerfumeOnMe/spring/external/fastapi/dto/FastApiPbtiRecommendResponse.java b/src/main/java/PerfumeOnMe/spring/external/fastapi/dto/FastApiPbtiRecommendResponse.java new file mode 100644 index 0000000..26929c6 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/external/fastapi/dto/FastApiPbtiRecommendResponse.java @@ -0,0 +1,76 @@ +package PerfumeOnMe.spring.external.fastapi.dto; + +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) { + this.perfumeRecommend = perfumeRecommend; + } + + @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; + private String brand; + private String description; + private String perfumeImageUrl; + } +} diff --git a/src/main/java/PerfumeOnMe/spring/external/fastapi/dto/FastApiRecommendRequest.java b/src/main/java/PerfumeOnMe/spring/external/fastapi/dto/FastApiRecommendRequest.java new file mode 100644 index 0000000..a6171db --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/external/fastapi/dto/FastApiRecommendRequest.java @@ -0,0 +1,44 @@ +package PerfumeOnMe.spring.external.fastapi.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +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; + + // 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 diff --git a/src/main/java/PerfumeOnMe/spring/external/fastapi/dto/FastApiRecommendResponse.java b/src/main/java/PerfumeOnMe/spring/external/fastapi/dto/FastApiRecommendResponse.java new file mode 100644 index 0000000..b2a4adc --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/external/fastapi/dto/FastApiRecommendResponse.java @@ -0,0 +1,26 @@ +package PerfumeOnMe.spring.external.fastapi.dto; + +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; + private String imageUrl; + } +} diff --git a/src/main/java/PerfumeOnMe/spring/external/openai/OpenAiApiClient.java b/src/main/java/PerfumeOnMe/spring/external/openai/OpenAiApiClient.java new file mode 100644 index 0000000..9d571a6 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/external/openai/OpenAiApiClient.java @@ -0,0 +1,56 @@ +package PerfumeOnMe.spring.external.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.pbti.web.dto.ChatGptRequest; +import PerfumeOnMe.spring.pbti.web.dto.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/external/openai/OpenAiService.java b/src/main/java/PerfumeOnMe/spring/external/openai/OpenAiService.java new file mode 100644 index 0000000..3130ed8 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/external/openai/OpenAiService.java @@ -0,0 +1,13 @@ +package PerfumeOnMe.spring.external.openai; + +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/external/openai/OpenAiServiceImpl.java b/src/main/java/PerfumeOnMe/spring/external/openai/OpenAiServiceImpl.java new file mode 100644 index 0000000..8dee343 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/external/openai/OpenAiServiceImpl.java @@ -0,0 +1,58 @@ +package PerfumeOnMe.spring.external.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 +public class OpenAiServiceImpl implements OpenAiService { + + private final OpenAiApiClient openAiApiClient; // GPT API 호출용 클라이언트 + + @Override + 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); + } + } +} diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/converter/FragranceConverter.java b/src/main/java/PerfumeOnMe/spring/fragrance/converter/FragranceConverter.java new file mode 100644 index 0000000..31a6777 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/converter/FragranceConverter.java @@ -0,0 +1,181 @@ +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.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 { + + /** + * 향수 상세 페이지 조회 API + */ + public static FragranceResponseDTO.FragranceDetailResult toDetailDto(Fragrance fragrance, boolean liked) { + 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(FragranceLocation::getLocation) + .filter(Objects::nonNull) + .map(Location::getName) + .collect(Collectors.toList())) + .seasons(fragrance.getFragranceSeasonList().stream() + .map(FragranceSeason::getSeason) + .filter(Objects::nonNull) + .map(Season::getName) + .collect(Collectors.toList())) + .homePageUrl(fragrance.getHomePageURL()) + .imageURL(fragrance.getImageURL()) + .liked(liked) + .build(); + } + + // ml 당 가격 추출 + private static List toPriceDtoList( + List fragrancePrices) { + return fragrancePrices.stream() + .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()); + } + + // 탑 노트 추출 + private static List extractTopNotes(List fragranceNotes) { + return fragranceNotes.stream() + .map(FragranceTopNote::getNote) + .filter(Objects::nonNull) + .map(Note::getName) + .collect(Collectors.toList()); + } + + // 미들 노트 추출 + private static List extractMiddleNotes(List fragranceNotes) { + return fragranceNotes.stream() + .map(FragranceMiddleNote::getNote) + .filter(Objects::nonNull) + .map(Note::getName) + .collect(Collectors.toList()); + } + + // 베이스 노트 추출 + private static List extractBaseNotes(List fragranceNotes) { + return fragranceNotes.stream() + .map(FragranceBaseNote::getNote) + .filter(Objects::nonNull) + .map(Note::getName) + .collect(Collectors.toList()); + } + + // 해당 노트, 노트 키워드, 노트 설명 저장 + private static FragranceResponseDTO.FragranceDetailResult.NoteDto.NoteSection toNoteSection(String keyword, + String description, List ingredients) { + return FragranceResponseDTO.FragranceDetailResult.NoteDto.NoteSection.builder() + .keywords(keyword) + .description(description) + .ingredients(ingredients) + .build(); + } + + /** + * 향수 검색 API + */ + + // 향수 반환 dto 변환 처리 + public static FragranceResponseDTO.FragranceSearchResult toSearchResultDto(Fragrance fragrance, boolean liked) { + 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()) + .liked(liked) + .build(); + } + + // 향수 목록 전체 반환 dto + public static FragranceResponseDTO.FragranceSearchFinalResult toSearchFinalResult( + List content, + boolean hasNext + ) { + return FragranceResponseDTO.FragranceSearchFinalResult.builder() + .content(content) + .hasNext(hasNext) + .build(); + } + + // 마이페이지 향수 추천(Md's Choice) 목록 반환 + public static FragranceResponseDTO.FragranceMdChoiceResult toMdChoiceResult( + List content, String name, String nickname) { + return FragranceResponseDTO.FragranceMdChoiceResult.builder() + .content(content) + .name(name) + .nickname(nickname) + .build(); + } + + // 향수 즐겨찾기 등록 API + public static FragranceResponseDTO.FavoriteResponseDTO toFavoriteResponseDTO(UserFragrance userFragrance) { + return FragranceResponseDTO.FavoriteResponseDTO.builder() + .fragranceId(userFragrance.getFragrance().getId()) + .build(); + } + + // 향수 즐겨찾기 취소 API + public static FragranceResponseDTO.FavoriteCancelResponseDTO toFavoriteCancelResponseDTO( + UserFragrance userFragrance) { + return FragranceResponseDTO.FavoriteCancelResponseDTO.builder() + .fragranceId(userFragrance.getFragrance().getId()) + .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/fragrance/domain/Fragrance.java b/src/main/java/PerfumeOnMe/spring/fragrance/domain/Fragrance.java new file mode 100644 index 0000000..94242e6 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/domain/Fragrance.java @@ -0,0 +1,131 @@ +package PerfumeOnMe.spring.fragrance.domain; + +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +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; +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 +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@DynamicInsert +@DynamicUpdate +@Table(name = "fragrances") +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(50)") + 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; + + @Column(nullable = false, unique = true) + private String keyword; + + //----- 매핑 관계 ----- + + @OneToMany(mappedBy = "fragrance", cascade = CascadeType.ALL) + @Builder.Default + private List userFragranceList = 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 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/fragrance/domain/Location.java b/src/main/java/PerfumeOnMe/spring/fragrance/domain/Location.java new file mode 100644 index 0000000..9c46bff --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/domain/Location.java @@ -0,0 +1,45 @@ +package PerfumeOnMe.spring.fragrance.domain; + +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +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; +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 = "locations") +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) + @Builder.Default + private List fragranceLocationList = new ArrayList<>(); + +} diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/domain/Note.java b/src/main/java/PerfumeOnMe/spring/fragrance/domain/Note.java new file mode 100644 index 0000000..cdc3415 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/domain/Note.java @@ -0,0 +1,80 @@ +package PerfumeOnMe.spring.fragrance.domain; + +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +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; +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 = "notes") +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 boolean top; + + @Column(nullable = false) + private boolean middle; + + @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 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<>(); + + // 메서드 + // 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/fragrance/domain/Price.java b/src/main/java/PerfumeOnMe/spring/fragrance/domain/Price.java new file mode 100644 index 0000000..1e4865c --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/domain/Price.java @@ -0,0 +1,47 @@ +package PerfumeOnMe.spring.fragrance.domain; + +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +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; +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 = "prices") +public class Price extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private int mlCount; + + @Column(nullable = false) + private int price; + + @OneToMany(mappedBy = "price", cascade = CascadeType.ALL) + @Builder.Default + private List fragrancePriceList = new ArrayList<>(); +} diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/domain/Season.java b/src/main/java/PerfumeOnMe/spring/fragrance/domain/Season.java new file mode 100644 index 0000000..77fc6aa --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/domain/Season.java @@ -0,0 +1,44 @@ +package PerfumeOnMe.spring.fragrance.domain; + +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +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; +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 = "seasons") +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) + @Builder.Default + private List fragranceSeasonList = new ArrayList<>(); +} diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceBaseNote.java b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceBaseNote.java new file mode 100644 index 0000000..5e80a19 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceBaseNote.java @@ -0,0 +1,43 @@ +package PerfumeOnMe.spring.fragrance.domain.mapping; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +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; +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_base_notes") +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/fragrance/domain/mapping/FragranceLocation.java b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceLocation.java new file mode 100644 index 0000000..799d452 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceLocation.java @@ -0,0 +1,44 @@ +package PerfumeOnMe.spring.fragrance.domain.mapping; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +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; +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_locations") +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/fragrance/domain/mapping/FragranceMiddleNote.java b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceMiddleNote.java new file mode 100644 index 0000000..60b3e7a --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceMiddleNote.java @@ -0,0 +1,43 @@ +package PerfumeOnMe.spring.fragrance.domain.mapping; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +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; +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_middle_notes") +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/fragrance/domain/mapping/FragrancePrice.java b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragrancePrice.java new file mode 100644 index 0000000..44e095f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragrancePrice.java @@ -0,0 +1,43 @@ +package PerfumeOnMe.spring.fragrance.domain.mapping; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +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; +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 extends BaseEntity { + @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; +} diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceSeason.java b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceSeason.java new file mode 100644 index 0000000..53d72c2 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceSeason.java @@ -0,0 +1,44 @@ +package PerfumeOnMe.spring.fragrance.domain.mapping; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +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; +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_seasons") +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/fragrance/domain/mapping/FragranceTopNote.java b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceTopNote.java new file mode 100644 index 0000000..e08c7ad --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/FragranceTopNote.java @@ -0,0 +1,43 @@ +package PerfumeOnMe.spring.fragrance.domain.mapping; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +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; +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_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 = "fragrance_id") + private Fragrance fragrance; +} diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/RecommendedFragrance.java b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/RecommendedFragrance.java new file mode 100644 index 0000000..558f8dd --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/domain/mapping/RecommendedFragrance.java @@ -0,0 +1,48 @@ +package PerfumeOnMe.spring.fragrance.domain.mapping; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +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; +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 = "recommended_fragrances") +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 = "pbti_id") + private PBTI pbti; + +} diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepository.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepository.java new file mode 100644 index 0000000..c6ab48e --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepository.java @@ -0,0 +1,55 @@ +package PerfumeOnMe.spring.fragrance.repository; + +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.fragrance.domain.Fragrance; + +public interface FragranceRepository extends JpaRepository, FragranceRepositoryCustom { + Optional findByName(String name); + + 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 + 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 9 + """, nativeQuery = true) + List findByUserMdChoice(@Param("gender") String gender, + @Param("noteIdList") List userNoteIdList); +} diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepositoryCustom.java new file mode 100644 index 0000000..1309194 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepositoryCustom.java @@ -0,0 +1,20 @@ +package PerfumeOnMe.spring.fragrance.repository; + +import java.util.Optional; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import PerfumeOnMe.spring.fragrance.domain.Fragrance; +import PerfumeOnMe.spring.fragrance.web.dto.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/fragrance/repository/FragranceRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepositoryImpl.java new file mode 100644 index 0000000..fb5026e --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/FragranceRepositoryImpl.java @@ -0,0 +1,214 @@ +package PerfumeOnMe.spring.fragrance.repository; + +import java.util.ArrayList; +import java.util.List; +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.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; +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; + +@Repository +@RequiredArgsConstructor +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"); + @PersistenceContext + private EntityManager em; + + // 향수 상세 + @Override + public Optional findByIdWithAllDetails(Long id) { + 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); + } + + //향수 검색 + @Override + public Page findBySearchKeyword(String keyword, Pageable pageable) { + // 1) 이름 OR 브랜드 매칭 조건 생성 + BooleanExpression predicate = nameOrBrandMatches(keyword); + + // 실제 결과 데이터 조회 + List content = queryFactory + .selectFrom(f) + .where(predicate) + .offset(pageable.getOffset()) // 몇 번째부터 가져올지 (page * size) + .limit(pageable.getPageSize()) // 몇 개 가져올지 + .fetch(); + + // 카운트 쿼리 -> 총 조회된 향수가 몇 개인지 + Long count = queryFactory + .select(f.count()) + .from(f) + .where(predicate) + .fetchOne(); + + // Page 객체 생성 + 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) { + + 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/fragrance/repository/fragranceBaseNote/FragranceBaseNoteRepository.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceBaseNote/FragranceBaseNoteRepository.java new file mode 100644 index 0000000..a1a9758 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceBaseNote/FragranceBaseNoteRepository.java @@ -0,0 +1,9 @@ +package PerfumeOnMe.spring.fragrance.repository.fragranceBaseNote; + +import org.springframework.data.jpa.repository.JpaRepository; + +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/fragrance/repository/fragranceBaseNote/FragranceBaseNoteRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceBaseNote/FragranceBaseNoteRepositoryImpl.java new file mode 100644 index 0000000..9fbe277 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceBaseNote/FragranceBaseNoteRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.fragrance.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/fragrance/repository/fragranceLocation/FragranceLocationRepository.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceLocation/FragranceLocationRepository.java new file mode 100644 index 0000000..3815f30 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceLocation/FragranceLocationRepository.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.fragrance.repository.fragranceLocation; + +import org.springframework.data.jpa.repository.JpaRepository; + +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/fragrance/repository/fragranceLocation/FragranceLocationRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceLocation/FragranceLocationRepositoryImpl.java new file mode 100644 index 0000000..a358bbd --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceLocation/FragranceLocationRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.fragrance.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/fragrance/repository/fragranceMiddleNote/FragranceMiddleNoteRepository.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceMiddleNote/FragranceMiddleNoteRepository.java new file mode 100644 index 0000000..1eaaf09 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceMiddleNote/FragranceMiddleNoteRepository.java @@ -0,0 +1,9 @@ +package PerfumeOnMe.spring.fragrance.repository.fragranceMiddleNote; + +import org.springframework.data.jpa.repository.JpaRepository; + +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/fragrance/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryImpl.java new file mode 100644 index 0000000..d933872 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceMiddleNote/FragranceMiddleNoteRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.fragrance.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/fragrance/repository/fragrancePrice/FragrancePriceRepository.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragrancePrice/FragrancePriceRepository.java new file mode 100644 index 0000000..009910d --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragrancePrice/FragrancePriceRepository.java @@ -0,0 +1,11 @@ +package PerfumeOnMe.spring.fragrance.repository.fragrancePrice; + +import org.springframework.data.jpa.repository.JpaRepository; + +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/fragrance/repository/fragrancePrice/FragrancePriceRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragrancePrice/FragrancePriceRepositoryImpl.java new file mode 100644 index 0000000..e8f0494 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragrancePrice/FragrancePriceRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.fragrance.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/fragrance/repository/fragranceSeason/FragranceSeasonRepository.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceSeason/FragranceSeasonRepository.java new file mode 100644 index 0000000..1fb823d --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceSeason/FragranceSeasonRepository.java @@ -0,0 +1,9 @@ +package PerfumeOnMe.spring.fragrance.repository.fragranceSeason; + +import org.springframework.data.jpa.repository.JpaRepository; + +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/fragrance/repository/fragranceSeason/FragranceSeasonRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceSeason/FragranceSeasonRepositoryImpl.java new file mode 100644 index 0000000..31e8a30 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceSeason/FragranceSeasonRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.fragrance.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/fragrance/repository/fragranceTopNote/FragranceTopNoteRepository.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceTopNote/FragranceTopNoteRepository.java new file mode 100644 index 0000000..49eeeb1 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceTopNote/FragranceTopNoteRepository.java @@ -0,0 +1,9 @@ +package PerfumeOnMe.spring.fragrance.repository.fragranceTopNote; + +import org.springframework.data.jpa.repository.JpaRepository; + +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/fragrance/repository/fragranceTopNote/FragranceTopNoteRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceTopNote/FragranceTopNoteRepositoryImpl.java new file mode 100644 index 0000000..a269bb3 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/fragranceTopNote/FragranceTopNoteRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.fragrance.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/fragrance/repository/location/LocationRepository.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/location/LocationRepository.java new file mode 100644 index 0000000..b8734fc --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/location/LocationRepository.java @@ -0,0 +1,11 @@ +package PerfumeOnMe.spring.fragrance.repository.location; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.fragrance.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/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/fragrance/repository/location/LocationRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/location/LocationRepositoryImpl.java new file mode 100644 index 0000000..d287a86 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/location/LocationRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.fragrance.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/fragrance/repository/note/NoteRepository.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/note/NoteRepository.java new file mode 100644 index 0000000..346574e --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/note/NoteRepository.java @@ -0,0 +1,11 @@ +package PerfumeOnMe.spring.fragrance.repository.note; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +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/fragrance/repository/note/NoteRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/note/NoteRepositoryImpl.java new file mode 100644 index 0000000..f9eb1c2 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/note/NoteRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.fragrance.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/fragrance/repository/price/PriceRepository.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/price/PriceRepository.java new file mode 100644 index 0000000..bd962f2 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/price/PriceRepository.java @@ -0,0 +1,11 @@ +package PerfumeOnMe.spring.fragrance.repository.price; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.fragrance.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/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/fragrance/repository/price/PriceRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/price/PriceRepositoryImpl.java new file mode 100644 index 0000000..612ac8a --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/price/PriceRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.fragrance.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/fragrance/repository/season/SeasonRepository.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/season/SeasonRepository.java new file mode 100644 index 0000000..6b0c961 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/season/SeasonRepository.java @@ -0,0 +1,11 @@ +package PerfumeOnMe.spring.fragrance.repository.season; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +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/fragrance/repository/season/SeasonRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/repository/season/SeasonRepositoryImpl.java new file mode 100644 index 0000000..f18c5a8 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/repository/season/SeasonRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.fragrance.repository.season; + +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class SeasonRepositoryImpl implements SeasonRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/service/FragranceService.java b/src/main/java/PerfumeOnMe/spring/fragrance/service/FragranceService.java new file mode 100644 index 0000000..c231dd4 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/service/FragranceService.java @@ -0,0 +1,34 @@ +package PerfumeOnMe.spring.fragrance.service; + +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 + FragranceResponseDTO.FragranceDetailResult getFragranceDetail(Long fragranceId, Long userId); + + // 향수 검색 API + FragranceResponseDTO.FragranceSearchFinalResult searchFragrances(FragranceRequestDTO.FragranceSearchRequest request, + Long userId); + + // 향수 즐겨찾기 등록 API + FragranceResponseDTO.FavoriteResponseDTO addFavorite(Long userId, Long fragranceId); + + // 향수 즐겨찾기 취소 API + FragranceResponseDTO.FavoriteCancelResponseDTO deleteFavorite(Long userId, Long fragranceId); + + // 향수 필터링 API + FragranceResponseDTO.FragranceSearchFinalResult searchFragrancesByFilter( + FragranceRequestDTO.FragranceFilterRequest request, Long userId); + + // 향수 전체 리스트 조회 API + FragranceResponseDTO.FragranceSearchFinalResult getFragranceListAll(FragranceRequestDTO.FragranceAllRequest request, + Long userId); + + FragranceResponseDTO.FragranceMdChoiceResult getFragranceMdChoice(CustomUserDetails userDetails); + + // 향수 메인페이지 나만의 향수 조회 API + FragranceResponseDTO.FragranceMyPerfumeResult getFragranceMyPerfume(CustomUserDetails userDetails); +} + diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/service/FragranceServiceImpl.java b/src/main/java/PerfumeOnMe/spring/fragrance/service/FragranceServiceImpl.java new file mode 100644 index 0000000..1415432 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/service/FragranceServiceImpl.java @@ -0,0 +1,294 @@ +package PerfumeOnMe.spring.fragrance.service; + +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.data.domain.Pageable; +import org.springframework.data.domain.Sort; +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.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 +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FragranceServiceImpl implements FragranceService { + + private final FragranceRepository fragranceRepository; + private final UserRepository userRepository; + private final UserFragranceRepository userFragranceRepository; + 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 + public FragranceResponseDTO.FragranceDetailResult getFragranceDetail(Long fragranceId, Long userId) { + Fragrance fragrance = fragranceRepository.findByIdWithAllDetails(fragranceId) + .orElseThrow(() -> new GeneralException(ErrorStatus.FRAGRANCE_NOT_FOUND)); + + boolean liked = (userId != null) && Like(userId, fragranceId); + + return FragranceConverter.toDetailDto(fragrance, liked); + } + + // 향수 검색 API + @Override + public FragranceResponseDTO.FragranceSearchFinalResult searchFragrances( + FragranceRequestDTO.FragranceSearchRequest request, Long userId) { + PageRequest pageable = PageRequest.of(request.getPage(), request.getSize()); + Page fragrancePage = fragranceRepository.findBySearchKeyword(request.getKeyword(), pageable); + + return getFragranceSearchFinalResult(userId, fragrancePage); + + } + + // 향수 즐겨찾기 등록 API + @Override + @Transactional(readOnly = false) + public FragranceResponseDTO.FavoriteResponseDTO addFavorite(Long userId, Long fragranceId) { + User user = findUserById(userId); + 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); + } + + // 향수 즐겨찾기 취소 API + @Override + @Transactional(readOnly = false) + public FragranceResponseDTO.FavoriteCancelResponseDTO deleteFavorite(Long userId, Long fragranceId) { + User user = findUserById(userId); + 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( + FragranceRequestDTO.FragranceFilterRequest request, Long userId) { + + // 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); + + return getFragranceSearchFinalResult(userId, fragrancePage); + } + + // 향수 전체 리스트 API + @Override + public FragranceResponseDTO.FragranceSearchFinalResult getFragranceListAll( + FragranceRequestDTO.FragranceAllRequest request, Long userId) { + 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 -> { + // 즐겨찾기 확인 + boolean liked = (userId != null) && Like(userId, fragrance.getId()); + return FragranceConverter.toSearchResultDto(fragrance, liked); + }) + .collect(Collectors.toList()); + + return FragranceConverter.toSearchFinalResult(content, fragrancePage.hasNext()); + } + + // 메인페이지 향수 추천(MD's Choice) 목록 조회 API + @Override + public FragranceResponseDTO.FragranceMdChoiceResult getFragranceMdChoice(CustomUserDetails userDetails) { + + // 사용자 조회 + User user = findUserById(userDetails.getUserId()); + + // 사용자 선호 향 조회 + List noteList = user.getUserNoteList().stream() + .map(userNote -> userNote.getNote().getId()) + .toList(); + + // 사용자 맞춤 향수 반환 + // 성별이 NONE인 경우, null 적용 (필터링 적용 X) + List userMdChoice = fragranceRepository + .findByUserMdChoice((user.getGender() == UserGender.NONE ? null : user.getGender().name()), noteList); + + // 즐겨찾기 적용해서 응답 DTO로 반환 + return getFragranceMdChoiceFinalResult(user.getId(), userMdChoice, user.getName(), user.getNickname()); + } + + // Md's Choice 목록에 즐겨찾기 정보 포함해서 최종 DTO 반환 + private FragranceResponseDTO.FragranceMdChoiceResult getFragranceMdChoiceFinalResult( + Long userId, List fragranceList, String name, String nickname) { + + List content = fragranceList.stream() + .map(fragrance -> { + boolean liked = (userId != null) && Like(userId, fragrance.getId()); + return FragranceConverter.toSearchResultDto(fragrance, liked); + }).toList(); + + return FragranceConverter.toMdChoiceResult(content, name, nickname); + } + + // 메인페이지 나만의 향수 조회 API + @Override + public FragranceResponseDTO.FragranceMyPerfumeResult getFragranceMyPerfume(CustomUserDetails userDetails) { + // 사용자 조회 + User user = findUserById(userDetails.getUserId()); + + 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(); + } + } + + /** + * 사용자 ID로 사용자 조회 (공통 메서드) + */ + private User findUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.LOGIN_ID_NOT_FOUND)); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/validation/annotation/ValidKeyword.java b/src/main/java/PerfumeOnMe/spring/fragrance/validation/annotation/ValidKeyword.java new file mode 100644 index 0000000..5e64435 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/validation/annotation/ValidKeyword.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.fragrance.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.fragrance.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/fragrance/validation/validator/ValidKeywordValidator.java b/src/main/java/PerfumeOnMe/spring/fragrance/validation/validator/ValidKeywordValidator.java new file mode 100644 index 0000000..530f6d8 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/validation/validator/ValidKeywordValidator.java @@ -0,0 +1,14 @@ +package PerfumeOnMe.spring.fragrance.validation.validator; + +import PerfumeOnMe.spring.fragrance.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 diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/web/controller/FragranceController.java b/src/main/java/PerfumeOnMe/spring/fragrance/web/controller/FragranceController.java new file mode 100644 index 0000000..ac69611 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/web/controller/FragranceController.java @@ -0,0 +1,119 @@ +package PerfumeOnMe.spring.fragrance.web.controller; + +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; +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.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 jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/fragrances") +public class FragranceController implements FragranceControllerDocs { + + private final FragranceService fragranceService; + + /** + * 향수 상세 API + */ + @GetMapping("/allow/{fragranceId}") + public ResponseEntity> getFragranceDetail( + @PathVariable("fragranceId") Long fragranceId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + Long userId = (userDetails != null) ? userDetails.getUserId() : null; + FragranceResponseDTO.FragranceDetailResult result = fragranceService.getFragranceDetail(fragranceId, + userId); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } + + /** + * 향수 검색 API + */ + @GetMapping("/allow/search") + public ResponseEntity> searchFragrances( + @Valid @ModelAttribute FragranceRequestDTO.FragranceSearchRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Long userId = (userDetails != null) ? userDetails.getUserId() : null; + FragranceResponseDTO.FragranceSearchFinalResult result = fragranceService.searchFragrances(request, userId); + + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + + } + + // 향수 즐겨찾기 등록 API + @PostMapping("/{fragranceId}/favorites") + public ResponseEntity> addFavorite( + @PathVariable("fragranceId") Long fragranceId, + @AuthenticationPrincipal CustomUserDetails userDetails) { + FragranceResponseDTO.FavoriteResponseDTO result = fragranceService.addFavorite(userDetails.getUserId(), + fragranceId); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } + + // 향수 즐겨찾기 취소 API + @DeleteMapping("/{fragranceId}/favorites") + 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 + */ + @GetMapping("/allow/filter") + public ResponseEntity> searchFragrancesByFilter( + @Valid @ModelAttribute FragranceRequestDTO.FragranceFilterRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + Long userId = (userDetails != null) ? userDetails.getUserId() : null; + FragranceResponseDTO.FragranceSearchFinalResult result = fragranceService.searchFragrancesByFilter(request, + userId); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } + + /** + * 향수 전체 리스트 API + */ + + @GetMapping("/allow/all") + 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)); + } + + @GetMapping("/md-choice") + public ResponseEntity> getFragrancesMdChoice( + @AuthenticationPrincipal CustomUserDetails userDetails) { + FragranceResponseDTO.FragranceMdChoiceResult result = fragranceService.getFragranceMdChoice(userDetails); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } + + @GetMapping("/my-perfume") + 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/fragrance/web/docs/FragranceControllerDocs.java b/src/main/java/PerfumeOnMe/spring/fragrance/web/docs/FragranceControllerDocs.java new file mode 100644 index 0000000..c061774 --- /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/fragrance/web/dto/FragranceRequestDTO.java b/src/main/java/PerfumeOnMe/spring/fragrance/web/dto/FragranceRequestDTO.java new file mode 100644 index 0000000..291b266 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/web/dto/FragranceRequestDTO.java @@ -0,0 +1,64 @@ +package PerfumeOnMe.spring.fragrance.web.dto; + +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; + +public class FragranceRequestDTO { + + // 향수 검색 요청 DTO + @Getter + @Setter + public static class FragranceSearchRequest { + + @ValidKeyword + private String keyword; + + @ValidPage + private int page; + + @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; + + } + + // 향수 전체 리스트 요청 DTO + @Getter + @Setter + public static class FragranceAllRequest { + @ValidPage + private int page; + + @ValidSize + private int size; + } + +} diff --git a/src/main/java/PerfumeOnMe/spring/fragrance/web/dto/FragranceResponseDTO.java b/src/main/java/PerfumeOnMe/spring/fragrance/web/dto/FragranceResponseDTO.java new file mode 100644 index 0000000..97af940 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/fragrance/web/dto/FragranceResponseDTO.java @@ -0,0 +1,148 @@ +package PerfumeOnMe.spring.fragrance.web.dto; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class FragranceResponseDTO { + + // 향수 상세 응답 DTO + @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; + private String imageURL; + private boolean liked; + + @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; + } + } + + // 향수 검색,필터링 응답 DTO (각 향수 단건) + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class FragranceSearchResult { + private Long id; + private String brand; + private String name; + private Integer minPrice; + private String imageUrl; + private boolean liked; + } + + // 향수 즐겨찾기 등록 응답 DTO + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class FavoriteResponseDTO { + private Long fragranceId; + } + + // 향수 즐겨찾기 등록 취소 DTO + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class FavoriteCancelResponseDTO { + private Long fragranceId; + } + + // 향수, 검색 필터링 응답 DTO (최종 응답) + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class FragranceSearchFinalResult { + private List content; + private boolean hasNext; + } + + // 메인페이지 향수 추천(MD's Choice) 목록 + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class FragranceMdChoiceResult { + private List content; + private String name; + 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; + } +} + + diff --git a/src/main/java/PerfumeOnMe/spring/imagekeyword/converter/ImageKeywordConverter.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/converter/ImageKeywordConverter.java new file mode 100644 index 0000000..2293229 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/converter/ImageKeywordConverter.java @@ -0,0 +1,87 @@ +package PerfumeOnMe.spring.imagekeyword.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.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 { + + 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(); + } + + 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); + } + } + + public static ImageKeywordResponseDTO.ImageKeywordSaveResponseDTO toSaveResponseDTO(ImageKeyword entity) { + return ImageKeywordResponseDTO.ImageKeywordSaveResponseDTO.builder() + .imageKeywordId(entity.getId()) + .savedName(entity.getSavedName()) + .createdAt(entity.getCreatedAt()) + .build(); + } + + private record EnumWithCategory(String keyword, KeywordCategory category) { + } +} diff --git a/src/main/java/PerfumeOnMe/spring/imagekeyword/domain/ImageKeyword.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/domain/ImageKeyword.java new file mode 100644 index 0000000..133d178 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/domain/ImageKeyword.java @@ -0,0 +1,82 @@ +package PerfumeOnMe.spring.imagekeyword.domain; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +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; +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 +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@DynamicInsert +@DynamicUpdate +@Table(name = "image_keywords") +public class ImageKeyword extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @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 Personality personality; + + @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; + +} diff --git a/src/main/java/PerfumeOnMe/spring/imagekeyword/domain/ImageKeywordDescription.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/domain/ImageKeywordDescription.java new file mode 100644 index 0000000..570754d --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/domain/ImageKeywordDescription.java @@ -0,0 +1,44 @@ +package PerfumeOnMe.spring.imagekeyword.domain; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +import PerfumeOnMe.spring.common.base.BaseEntity; +import PerfumeOnMe.spring.common.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; +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; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private KeywordCategory category; + + @Column(nullable = false, columnDefinition = "TEXT") + private String description; +} diff --git a/src/main/java/PerfumeOnMe/spring/imagekeyword/redis/ImageKeywordRedisService.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/redis/ImageKeywordRedisService.java new file mode 100644 index 0000000..f639722 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/redis/ImageKeywordRedisService.java @@ -0,0 +1,55 @@ +package PerfumeOnMe.spring.imagekeyword.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.imagekeyword.web.dto.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 diff --git a/src/main/java/PerfumeOnMe/spring/imagekeyword/repository/ImageKeywordRepository.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/repository/ImageKeywordRepository.java new file mode 100644 index 0000000..4da2cbb --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/repository/ImageKeywordRepository.java @@ -0,0 +1,20 @@ +package PerfumeOnMe.spring.imagekeyword.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.imagekeyword.domain.ImageKeyword; +import PerfumeOnMe.spring.user.domain.User; + +public interface ImageKeywordRepository extends JpaRepository { + List findAllByUserOrderByCreatedAtDesc(User user); + + Optional findByIdAndUser(Long id, User user); + + // 동일한 사용자와 저장 이름이 존재하는지 확인 + boolean existsByUserAndSavedName(User user, String savedName); + + Optional findFirstByUserOrderByCreatedAtDesc(User user); +} diff --git a/src/main/java/PerfumeOnMe/spring/imagekeyword/repository/imagekeyworddescription/ImageKeywordDescriptionRepository.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/repository/imagekeyworddescription/ImageKeywordDescriptionRepository.java new file mode 100644 index 0000000..43a2286 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/repository/imagekeyworddescription/ImageKeywordDescriptionRepository.java @@ -0,0 +1,13 @@ +package PerfumeOnMe.spring.imagekeyword.repository.imagekeyworddescription; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +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/imagekeyword/service/ImageKeywordDescriptionService.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/service/ImageKeywordDescriptionService.java new file mode 100644 index 0000000..20af83f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/service/ImageKeywordDescriptionService.java @@ -0,0 +1,49 @@ +package PerfumeOnMe.spring.imagekeyword.service; + +import java.util.List; +import java.util.stream.Stream; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +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 +@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/imagekeyword/service/ImageKeywordPreviewService.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/service/ImageKeywordPreviewService.java new file mode 100644 index 0000000..f2c6d4b --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/service/ImageKeywordPreviewService.java @@ -0,0 +1,90 @@ +package PerfumeOnMe.spring.imagekeyword.service; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +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 +@RequiredArgsConstructor +@Transactional +public class ImageKeywordPreviewService { + + 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()) + .imageUrl(f.getImageUrl()) + .build() + ).collect(Collectors.toList()); + + // ✅ 5. 분위기에 따른 감성 캐릭터 이미지 URL 선택 + String characterImageUrl = CharacterImageMapper.getCharacterImageUrl(request.getAmbience()); + + // ✅ 6. 최종 Preview 응답 구성 + ImageKeywordPreviewResponseDTO previewDTO = ImageKeywordPreviewResponseDTO.builder() + .keywords(keywords) + .descriptions(descriptions) + .scenario(fastApiResponse.getScenario()) + .characterImageUrl(characterImageUrl) + .recommendations(recommendations) + .build(); + + // ✅ 7. Redis 저장 (TTL 15분) + redisService.savePreview(userId, previewDTO); + + return previewDTO; + } +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/imagekeyword/service/ImageKeywordService.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/service/ImageKeywordService.java new file mode 100644 index 0000000..11dbcfd --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/service/ImageKeywordService.java @@ -0,0 +1,13 @@ +package PerfumeOnMe.spring.imagekeyword.service; + +import java.util.List; + +import PerfumeOnMe.spring.imagekeyword.web.dto.ImageKeywordResponseDTO; + +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/imagekeyword/service/ImageKeywordServiceImpl.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/service/ImageKeywordServiceImpl.java new file mode 100644 index 0000000..27834f8 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/service/ImageKeywordServiceImpl.java @@ -0,0 +1,100 @@ +package PerfumeOnMe.spring.imagekeyword.service; + +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.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 +@RequiredArgsConstructor +@Transactional +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) + 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); + } + + @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)); + + // 이미지 키워드 결과 존재 여부 검증 + 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); + } + + @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 diff --git a/src/main/java/PerfumeOnMe/spring/imagekeyword/util/ImageKeywordDescriptionUtils.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/util/ImageKeywordDescriptionUtils.java new file mode 100644 index 0000000..0a970be --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/util/ImageKeywordDescriptionUtils.java @@ -0,0 +1,84 @@ +package PerfumeOnMe.spring.imagekeyword.util; + +import java.util.List; +import java.util.stream.Stream; + +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; + +/** + * 이미지 키워드 설명 조회 공통 유틸리티 + */ +@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/imagekeyword/util/ImageKeywordValidationUtils.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/util/ImageKeywordValidationUtils.java new file mode 100644 index 0000000..878c5ac --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/util/ImageKeywordValidationUtils.java @@ -0,0 +1,27 @@ +package PerfumeOnMe.spring.imagekeyword.util; + +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.user.domain.User; +import PerfumeOnMe.spring.user.repository.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 diff --git a/src/main/java/PerfumeOnMe/spring/imagekeyword/validation/annotation/ValidEnumKeyword.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/validation/annotation/ValidEnumKeyword.java new file mode 100644 index 0000000..21d5062 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/validation/annotation/ValidEnumKeyword.java @@ -0,0 +1,26 @@ +package PerfumeOnMe.spring.imagekeyword.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.imagekeyword.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/imagekeyword/validation/validator/ValidEnumKeywordValidator.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/validation/validator/ValidEnumKeywordValidator.java new file mode 100644 index 0000000..130eb37 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/validation/validator/ValidEnumKeywordValidator.java @@ -0,0 +1,35 @@ +package PerfumeOnMe.spring.imagekeyword.validation.validator; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import PerfumeOnMe.spring.imagekeyword.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; + } + }); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/imagekeyword/web/controller/ImageKeywordController.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/web/controller/ImageKeywordController.java new file mode 100644 index 0000000..5e713b3 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/web/controller/ImageKeywordController.java @@ -0,0 +1,76 @@ +package PerfumeOnMe.spring.imagekeyword.web.controller; + +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.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.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 +@RequiredArgsConstructor +@RequestMapping("/image-keyword") +public class ImageKeywordController implements ImageKeywordControllerDocs { + private final ImageKeywordService imageKeywordService; + private final ImageKeywordPreviewService previewService; + + /**(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); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } + + /**(2) 이미지 키워드 결과 저장 (save)*/ + @PostMapping("/save") + public ResponseEntity> saveImageKeywordResult( + @AuthenticationPrincipal CustomUserDetails userDetails, + @Validated @RequestBody ImageKeywordRequestDTO.ImageKeywordSaveRequestDTO request + ) { + ImageKeywordResponseDTO.ImageKeywordSaveResponseDTO result = + imageKeywordService.saveImageKeyword(userDetails.getUserId(), request.getSavedName()); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } + + /**(3) 이미지 키워드 결과 상세조회*/ + @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)); + } + + /**(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 diff --git a/src/main/java/PerfumeOnMe/spring/imagekeyword/web/docs/ImageKeywordControllerDocs.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/web/docs/ImageKeywordControllerDocs.java new file mode 100644 index 0000000..a1eade6 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/web/docs/ImageKeywordControllerDocs.java @@ -0,0 +1,233 @@ +package PerfumeOnMe.spring.imagekeyword.web.docs; + +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.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; +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 = "인증이 필요합니다. 액세스 토큰을 입력해주세요.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "COMMON401", + "message": "인증이 필요합니다." + } + """) + ) + ) + } + ) + ResponseEntity>> getImageKeywordList( + @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 = ImageKeywordResponseDTO.ImageKeywordDetailResponseDTO.class) + ) + ), + @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 = "IMAGEKEYWORD4003", + description = "해당 이미지 키워드 결과 정보가 존재하지 않습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "IMAGEKEYWORD4003", + "message": "해당 이미지 키워드 결과 정보가 존재하지 않습니다." + } + """) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "IMAGEKEYWORD4004", + description = "해당 이미지 키워드 결과에 접근할 수 없습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "IMAGEKEYWORD4004", + "message": "해당 이미지 키워드 결과에 접근할 수 없습니다." + } + """) + ) + ) + } + ) + ResponseEntity> getImageKeywordDetail( + @Parameter(description = "조회할 이미지 키워드 결과 ID", required = true, example = "1") + @PathVariable Long imageKeywordId, + @Parameter(hidden = true) + @AuthenticationPrincipal CustomUserDetails userDetails + ); + + @Operation( + summary = "이미지 키워드 결과 미리보기", + description = "사용자가 선택한 5가지 키워드(분위기, 스타일, 성별, 계절, 성격)를 바탕으로 감성 시나리오 및 향수 추천 결과를 생성하여 미리 확인합니다. " + + "결과는 Redis에 15분간 임시 저장되며, 이미지 키워드 저장 API 호출 시 활용됩니다.", + responses = { + @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에 임시 저장된 미리보기 데이터를 사용하므로, 미리보기 생성 후 15분 이내에 호출해야 합니다.", + responses = { + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "COMMON200", + description = "요청에 성공하였습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = ImageKeywordResponseDTO.ImageKeywordSaveResponseDTO.class) + ) + ), + @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 diff --git a/src/main/java/PerfumeOnMe/spring/imagekeyword/web/dto/ImageKeywordRequestDTO.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/web/dto/ImageKeywordRequestDTO.java new file mode 100644 index 0000000..1860b29 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/web/dto/ImageKeywordRequestDTO.java @@ -0,0 +1,53 @@ +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; +import lombok.Getter; + +/** + * 이미지 키워드 관련 요청 DTO 모음 클래스 + */ +public class ImageKeywordRequestDTO { + @Builder + @Getter + @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 + @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/imagekeyword/web/dto/ImageKeywordResponseDTO.java b/src/main/java/PerfumeOnMe/spring/imagekeyword/web/dto/ImageKeywordResponseDTO.java new file mode 100644 index 0000000..4989fc2 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/imagekeyword/web/dto/ImageKeywordResponseDTO.java @@ -0,0 +1,112 @@ +package PerfumeOnMe.spring.imagekeyword.web.dto; + +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 - 마이페이지 목록 조회용 + @Getter + @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 + @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; + private String imageUrl; + } + } + + // Preview 응답용 + @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; + private String imageUrl; + } + } + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class ImageKeywordSaveResponseDTO { + private Long imageKeywordId; + private String savedName; + private LocalDateTime createdAt; + } + +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/pbti/converter/PbtiConverter.java b/src/main/java/PerfumeOnMe/spring/pbti/converter/PbtiConverter.java new file mode 100644 index 0000000..a1de7bd --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/pbti/converter/PbtiConverter.java @@ -0,0 +1,80 @@ +package PerfumeOnMe.spring.pbti.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.pbti.domain.PBTI; +import PerfumeOnMe.spring.pbti.web.dto.PbtiResponseDTO; + +public class PbtiConverter { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + // 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(); + } + + // 마이페이지 PBTI 목록 조회 API + public static PbtiResponseDTO.PbtiListResult toPbtiListResult(PBTI pbti) { + return PbtiResponseDTO.PbtiListResult.builder() + .id(pbti.getId()) + .savedName(pbti.getSavedName()) + .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); + } + } + + // 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/pbti/domain/PBTI.java b/src/main/java/PerfumeOnMe/spring/pbti/domain/PBTI.java new file mode 100644 index 0000000..eb3ebad --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/pbti/domain/PBTI.java @@ -0,0 +1,98 @@ +package PerfumeOnMe.spring.pbti.domain; + +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +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; +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 +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@DynamicInsert +@DynamicUpdate +@Table(name = "pbtis") +public class PBTI extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id") + private User user; + + @Column(nullable = false, length = 50) + private String savedName; + + @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; + + @Column(columnDefinition = "json", nullable = false) + private String keywords; + + @Column(columnDefinition = "json", nullable = false) + private String perfumeStyle; + + @Column(columnDefinition = "json", nullable = false) + private String scentPoint; + + @Column(columnDefinition = "text", nullable = false) + private String summary; + + @Column(columnDefinition = "json", nullable = false) + private String perfumeRecommend; + + @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/pbti/repository/PbtiRepository.java b/src/main/java/PerfumeOnMe/spring/pbti/repository/PbtiRepository.java new file mode 100644 index 0000000..25abb30 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/pbti/repository/PbtiRepository.java @@ -0,0 +1,15 @@ +package PerfumeOnMe.spring.pbti.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.pbti.domain.PBTI; + +public interface PbtiRepository extends JpaRepository, PbtiRepositoryCustom { + + Optional findById(Long id); + + List findAllByUserId(Long userId); +} 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/pbti/repository/PbtiRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/pbti/repository/PbtiRepositoryImpl.java new file mode 100644 index 0000000..3d3f6e2 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/pbti/repository/PbtiRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.pbti.repository; + +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class PbtiRepositoryImpl implements PbtiRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/pbti/service/PbtiService.java b/src/main/java/PerfumeOnMe/spring/pbti/service/PbtiService.java new file mode 100644 index 0000000..5a34d44 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/pbti/service/PbtiService.java @@ -0,0 +1,27 @@ +package PerfumeOnMe.spring.pbti.service; + +import PerfumeOnMe.spring.pbti.web.dto.PbtiRequestDTO; +import PerfumeOnMe.spring.pbti.web.dto.PbtiResponseDTO; + +public interface PbtiService { + + // PBTI 결과 조회 API + PbtiResponseDTO.PbtiQuestionResponse searchPbti(Long userId, PbtiRequestDTO.PbtiQuestionRequest request); + + // PBTI 결과 저장 API + PbtiResponseDTO.PbtiSaveResponse savePbti(Long userId, PbtiRequestDTO.PbtiSaveRequest request); + + // 마이페이지 PBTI 목록 조회 API + PbtiResponseDTO.SearchPbtiListResponse searchPbtiList(Long userId); + + // 마이페이지 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/pbti/service/PbtiServiceImpl.java b/src/main/java/PerfumeOnMe/spring/pbti/service/PbtiServiceImpl.java new file mode 100644 index 0000000..f26ed39 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/pbti/service/PbtiServiceImpl.java @@ -0,0 +1,243 @@ +package PerfumeOnMe.spring.pbti.service; + +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; +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.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; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class PbtiServiceImpl implements PbtiService { + + private final ObjectMapper objectMapper; + private final PbtiRepository pbtiRepository; + private final UserRepository userRepository; + private final StringRedisTemplate stringRedisTemplate; + private final FastApiClient fastApiClient; + + // PBTI 결과 조회 API + @Override + public PbtiResponseDTO.PbtiQuestionResponse searchPbti(Long userId, PbtiRequestDTO.PbtiQuestionRequest request) { + + // 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.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() + .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); + } + + // 마이페이지 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(); + } + + // 마이페이지 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); + } + + // 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/pbti/web/controller/PbtiController.java b/src/main/java/PerfumeOnMe/spring/pbti/web/controller/PbtiController.java new file mode 100644 index 0000000..449db0f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/pbti/web/controller/PbtiController.java @@ -0,0 +1,90 @@ +package PerfumeOnMe.spring.pbti.web.controller; + +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; +import org.springframework.web.bind.annotation.RestController; + +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 lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/pbti") +public class PbtiController implements PbtiControllerDocs { + + private final PbtiService pbtiService; + + // PBTI 결과 조회 API + @PostMapping("/result") + public ResponseEntity> searchPbti( + @RequestBody PbtiRequestDTO.PbtiQuestionRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + PbtiResponseDTO.PbtiQuestionResponse result = pbtiService.searchPbti(userDetails.getUserId(), request); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } + + // PBTI 결과 저장 API + @PostMapping("/save") + public ResponseEntity> savePbti( + @RequestBody PbtiRequestDTO.PbtiSaveRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + PbtiResponseDTO.PbtiSaveResponse result = pbtiService.savePbti(userDetails.getUserId(), request); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } + + // 마이페이지 PBTI 목록 조회 API + @GetMapping("/result/list") + public ResponseEntity> searchPbtiList( + @AuthenticationPrincipal CustomUserDetails userDetails) { + + PbtiResponseDTO.SearchPbtiListResponse result = pbtiService.searchPbtiList(userDetails.getUserId()); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } + + // 마이페이지 PBTI 결과 상세 조회 API + @PostMapping("/detailResult") + public ResponseEntity> searchPbtiResult( + @RequestBody PbtiRequestDTO.PbtiResultDetailRequest request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + + PbtiResponseDTO.PbtiResultDetailResponse result = pbtiService.searchPbtiResult(userDetails.getUserId(), + request); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } + + // PBTI 결과 이름 수정 API + @PatchMapping("/{pbtiId}/name") + 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") + 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/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/pbti/web/dto/ChatGptRequest.java b/src/main/java/PerfumeOnMe/spring/pbti/web/dto/ChatGptRequest.java new file mode 100644 index 0000000..7916a0d --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/pbti/web/dto/ChatGptRequest.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.pbti.web.dto; + +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/pbti/web/dto/ChatGptResponse.java b/src/main/java/PerfumeOnMe/spring/pbti/web/dto/ChatGptResponse.java new file mode 100644 index 0000000..0006d23 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/pbti/web/dto/ChatGptResponse.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.pbti.web.dto; + +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/pbti/web/dto/PbtiRequestDTO.java b/src/main/java/PerfumeOnMe/spring/pbti/web/dto/PbtiRequestDTO.java new file mode 100644 index 0000000..2b34d1b --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/pbti/web/dto/PbtiRequestDTO.java @@ -0,0 +1,65 @@ +package PerfumeOnMe.spring.pbti.web.dto; + +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; + } + + // PBTI 결과 저장 요청 DTO + @Getter + @Setter + public static class PbtiSaveRequest { + private String savedName; + } + + // PBTI 결과 상세 조회 요청 DTO + @Getter + @Setter + 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/pbti/web/dto/PbtiResponseDTO.java b/src/main/java/PerfumeOnMe/spring/pbti/web/dto/PbtiResponseDTO.java new file mode 100644 index 0000000..70f5742 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/pbti/web/dto/PbtiResponseDTO.java @@ -0,0 +1,232 @@ +package PerfumeOnMe.spring.pbti.web.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +public class PbtiResponseDTO { + + // PBTI 결과 조회 응답 DTO + @Getter + @Setter + @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 + @Setter + @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; + private String perfumeImageUrl; + } + } + + // Pbti 키워드 결과 DTO + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class PbtiResult { + private String keyword1; + private String keyword2; + 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; + } + + // 마이페이지 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; + } + + // 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; + private String perfumeImageUrl; + } + } + + // PBTI 결과 이름 수정 응답 DTO + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class UpdatePbtiNameResponse { + private Long id; + private String savedName; + } + +} diff --git a/src/main/java/PerfumeOnMe/spring/s3file/aws/AmazonS3Manager.java b/src/main/java/PerfumeOnMe/spring/s3file/aws/AmazonS3Manager.java new file mode 100644 index 0000000..d39ce16 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/s3file/aws/AmazonS3Manager.java @@ -0,0 +1,75 @@ +package PerfumeOnMe.spring.s3file.aws; + +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; + +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; + +@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(); + } + + // 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/s3file/converter/S3Converter.java b/src/main/java/PerfumeOnMe/spring/s3file/converter/S3Converter.java new file mode 100644 index 0000000..b6c0edc --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/s3file/converter/S3Converter.java @@ -0,0 +1,16 @@ +package PerfumeOnMe.spring.s3file.converter; + +import PerfumeOnMe.spring.s3file.web.dto.s3ResponseDTO; +import PerfumeOnMe.spring.uuid.domain.Uuid; + +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/s3file/web/controller/S3Controller.java b/src/main/java/PerfumeOnMe/spring/s3file/web/controller/S3Controller.java new file mode 100644 index 0000000..dcd18ac --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/s3file/web/controller/S3Controller.java @@ -0,0 +1,68 @@ +package PerfumeOnMe.spring.s3file.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.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 lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/s3") +public class S3Controller implements S3ControllerDocs { + + private final AmazonS3Manager amazonS3Manager; + private final UuidRepository uuidRepository; + + @PostMapping("/upload-url") + 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/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/s3file/web/dto/s3RequestDTO.java b/src/main/java/PerfumeOnMe/spring/s3file/web/dto/s3RequestDTO.java new file mode 100644 index 0000000..5189811 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/s3file/web/dto/s3RequestDTO.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.s3file.web.dto; + +import lombok.Getter; + +public class s3RequestDTO { + @Getter + public static class PresignedUrlRequestDTO { + private String fileName; // e.g. "profile.png" + } +} diff --git a/src/main/java/PerfumeOnMe/spring/s3file/web/dto/s3ResponseDTO.java b/src/main/java/PerfumeOnMe/spring/s3file/web/dto/s3ResponseDTO.java new file mode 100644 index 0000000..8914681 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/s3file/web/dto/s3ResponseDTO.java @@ -0,0 +1,14 @@ +package PerfumeOnMe.spring.s3file.web.dto; + +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 + } +} diff --git a/src/main/java/PerfumeOnMe/spring/security/AuthenticationManagerConfig.java b/src/main/java/PerfumeOnMe/spring/security/AuthenticationManagerConfig.java new file mode 100644 index 0000000..f503f89 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/AuthenticationManagerConfig.java @@ -0,0 +1,34 @@ +package PerfumeOnMe.spring.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.security.auth.provider.CustomLoginAuthenticationProvider; +import PerfumeOnMe.spring.security.auth.provider.JwtAuthenticationProvider; +import lombok.RequiredArgsConstructor; + +/* +SecurityConfig와의 빈 순환 참조를 방지하기 위해 클래스 분리 + */ +@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/security/PasswordEncoderConfig.java b/src/main/java/PerfumeOnMe/spring/security/PasswordEncoderConfig.java new file mode 100644 index 0000000..995d043 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/PasswordEncoderConfig.java @@ -0,0 +1,19 @@ +package PerfumeOnMe.spring.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; + +/* +SecurityConfig와의 빈 순환 참조를 방지하기 위해 클래스 분리 + */ +@Configuration +public class PasswordEncoderConfig { + + // 문자열 암호화를 위한 PasswordEncoder 빈 등록 + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} 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..e771afe --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/SecurityConfig.java @@ -0,0 +1,86 @@ +package PerfumeOnMe.spring.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.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.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 +@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/**", "/users/reissue", + "/health", "/fragrances/allow/**", "/favicon.ico", "/images/**", + "/css/**", "/js/**", "/webjars/**" + }; + 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 + .requestMatchers(AUTH_WHITELIST).permitAll() + .anyRequest().authenticated() // 개발 진행할 때 임시로 풀어두기 -> 나중에 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) + // CORS에 아래에서 등록한 빈 설정 + .cors(cors -> cors.configurationSource(corsConfigurationSource())); + + return http.build(); + } + + // CORS 설정 및 빈 등록 + @Bean + 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://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); + config.setExposedHeaders(List.of("Authorization", "Refresh-Token", "Content-Type")); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} diff --git a/src/main/java/PerfumeOnMe/spring/security/auth/controller/LoginController.java b/src/main/java/PerfumeOnMe/spring/security/auth/controller/LoginController.java new file mode 100644 index 0000000..d3736e6 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/auth/controller/LoginController.java @@ -0,0 +1,34 @@ +package PerfumeOnMe.spring.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.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 jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/auth") +@RequiredArgsConstructor +public class LoginController implements LoginControllerDocs { + + private final LoginService loginService; + + @PostMapping("/login") + 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/security/auth/converter/AuthConverter.java b/src/main/java/PerfumeOnMe/spring/security/auth/converter/AuthConverter.java new file mode 100644 index 0000000..e993a1d --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/auth/converter/AuthConverter.java @@ -0,0 +1,18 @@ +package PerfumeOnMe.spring.security.auth.converter; + +import PerfumeOnMe.spring.common.enums.Social; +import PerfumeOnMe.spring.security.auth.dto.AuthResponseDTO; + +public class AuthConverter { + + 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/docs/LoginControllerDocs.java b/src/main/java/PerfumeOnMe/spring/security/auth/docs/LoginControllerDocs.java new file mode 100644 index 0000000..24506b5 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/auth/docs/LoginControllerDocs.java @@ -0,0 +1,32 @@ +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 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( + summary = "로그인 API", + description = "사용자의 아이디와 비밀번호로 로그인을 진행하는 API입니다.", + responses = { + @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 = "해당 아이디를 가진 사용자가 존재하지 않습니다."), + } + ) + 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/auth/dto/AuthRequestDTO.java b/src/main/java/PerfumeOnMe/spring/security/auth/dto/AuthRequestDTO.java new file mode 100644 index 0000000..6d962db --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/auth/dto/AuthRequestDTO.java @@ -0,0 +1,20 @@ +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; + +public class AuthRequestDTO { + + @Getter + @NoArgsConstructor + public static class Login { + @NotNull + @Schema(description = "사용자가 입력한 아이디", example = "umc123") + private String loginId; + @NotNull + @Schema(description = "사용자가 입력한 비밀번호", example = "asdf1234") + private String password; + } +} diff --git a/src/main/java/PerfumeOnMe/spring/security/auth/dto/AuthResponseDTO.java b/src/main/java/PerfumeOnMe/spring/security/auth/dto/AuthResponseDTO.java new file mode 100644 index 0000000..8c48dcc --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/auth/dto/AuthResponseDTO.java @@ -0,0 +1,21 @@ +package PerfumeOnMe.spring.security.auth.dto; + +import PerfumeOnMe.spring.common.enums.Social; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class AuthResponseDTO { + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + 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/dto/JwtProperties.java b/src/main/java/PerfumeOnMe/spring/security/auth/dto/JwtProperties.java new file mode 100644 index 0000000..fbe4740 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/auth/dto/JwtProperties.java @@ -0,0 +1,27 @@ +package PerfumeOnMe.spring.security.auth.dto; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import lombok.Getter; +import lombok.Setter; + +/* +application.yml에 설정해둔 값을 담아오기 위한 DTO + */ +@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; + } +} diff --git a/src/main/java/PerfumeOnMe/spring/security/auth/filter/JwtAuthenticationFilter.java b/src/main/java/PerfumeOnMe/spring/security/auth/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..aaefb60 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/auth/filter/JwtAuthenticationFilter.java @@ -0,0 +1,61 @@ +package PerfumeOnMe.spring.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.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +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; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/* +요청에서 토큰을 추출해 유효성을 검증하고, +Authentication을 SecurityContextHolder에 설정하는 클래스 + */ +@Component +@RequiredArgsConstructor +@Slf4j +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, + FilterChain filterChain) throws ServletException, IOException { + + log.debug("requestURI: {}, httpMethod: {}", request.getRequestURI(), request.getMethod()); + + String accessToken = jwtTokenProvider.resolveToken(request); + + 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, Social.LOCAL); + Authentication authResult = authenticationManager.authenticate(authRequest); + SecurityContextHolder.getContext().setAuthentication(authResult); + } + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/security/auth/filter/JwtExceptionHandlerFilter.java b/src/main/java/PerfumeOnMe/spring/security/auth/filter/JwtExceptionHandlerFilter.java new file mode 100644 index 0000000..3c1211a --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/auth/filter/JwtExceptionHandlerFilter.java @@ -0,0 +1,44 @@ +package PerfumeOnMe.spring.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 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 { + + @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) { + log.error(e.getMessage(), e); // Exception Logging + setErrorResponse(response, ErrorStatus._INTERNAL_SERVER_ERROR, e); + } + } +} diff --git a/src/main/java/PerfumeOnMe/spring/security/auth/filter/JwtLoginFilter.java b/src/main/java/PerfumeOnMe/spring/security/auth/filter/JwtLoginFilter.java new file mode 100644 index 0000000..1304350 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/auth/filter/JwtLoginFilter.java @@ -0,0 +1,124 @@ +package PerfumeOnMe.spring.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.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; +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 RefreshTokenManager refreshTokenManager; + private final LogoutAccessTokenManager logoutAccessTokenManager; + + private final ObjectMapper mapper = new ObjectMapper(); + + // AuthenticationManager 주입 및 처리할 URL 설정 + @Autowired + public void setAuthenticationManager(AuthenticationManager authenticationManager) { + super.setAuthenticationManager(authenticationManager); + setFilterProcessesUrl("/auth/login/filter"); // 접근하지 못하게 경로 수정 + } + + // 인증 시도 + @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 { + + // Authentication에서 principal String 추출 + String loginId = authResult.getName(); + Long userId = ((CustomUserDetails)authResult.getPrincipal()).getUserId(); + String name = ((CustomUserDetails)authResult.getPrincipal()).getName(); + + // 사용자의 로그아웃 액세스 토큰이 존재하는 경우 삭제 + if (logoutAccessTokenManager.findLogoutAccessToken(loginId)) { + logoutAccessTokenManager.deleteLogoutAccessToken(loginId); + } + + // 토큰 생성 및 DTO에 담기 + String accessToken = jwtTokenProvider.createAccessToken(authResult); + String refreshToken = jwtTokenProvider.createRefreshToken(authResult); + AuthResponseDTO.LoginResult loginResultDTO = AuthConverter.toLoginResult(refreshToken, userId, Social.LOCAL, + name); + + // 리프레시 토큰을 Redis에 저장 + refreshTokenManager.saveRefreshToken(loginId, refreshToken); + + // 응답 헤더 작성 + response.setCharacterEncoding("UTF-8"); + response.setContentType("application/json"); + response.setStatus(HttpServletResponse.SC_OK); + response.setHeader("Authorization", "Bearer " + accessToken); + + // 응답 데이터 생성 및 작성 + ApiResponse res = ApiResponse.onSuccess(loginResultDTO); + 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); + } + } +} diff --git a/src/main/java/PerfumeOnMe/spring/security/auth/handler/JwtAccessDeniedHandler.java b/src/main/java/PerfumeOnMe/spring/security/auth/handler/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..098ecbd --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/auth/handler/JwtAccessDeniedHandler.java @@ -0,0 +1,39 @@ +package PerfumeOnMe.spring.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)); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/security/auth/handler/JwtAuthenticationEntryPoint.java b/src/main/java/PerfumeOnMe/spring/security/auth/handler/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..4ec543e --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/auth/handler/JwtAuthenticationEntryPoint.java @@ -0,0 +1,39 @@ +package PerfumeOnMe.spring.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)); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/security/auth/manager/LogoutAccessTokenManager.java b/src/main/java/PerfumeOnMe/spring/security/auth/manager/LogoutAccessTokenManager.java new file mode 100644 index 0000000..44206cc --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/auth/manager/LogoutAccessTokenManager.java @@ -0,0 +1,42 @@ +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.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); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/security/auth/manager/RefreshTokenManager.java b/src/main/java/PerfumeOnMe/spring/security/auth/manager/RefreshTokenManager.java new file mode 100644 index 0000000..27a4190 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/auth/manager/RefreshTokenManager.java @@ -0,0 +1,45 @@ +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.security.auth.provider.JwtTokenProvider; +import lombok.RequiredArgsConstructor; + +/* +리프레시 토큰을 Redis에 저장, 수정, 삭제를 담당하는 클래스 + */ +@Component +@RequiredArgsConstructor +public class RefreshTokenManager { + + 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); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/security/auth/provider/CustomLoginAuthenticationProvider.java b/src/main/java/PerfumeOnMe/spring/security/auth/provider/CustomLoginAuthenticationProvider.java new file mode 100644 index 0000000..5409b21 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/auth/provider/CustomLoginAuthenticationProvider.java @@ -0,0 +1,44 @@ +package PerfumeOnMe.spring.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; + +/* +로그인 시 사용되는 AuthenticationProvider +요청에서 아이디와 비밀번호를 추출해 검증하고, 성공하면 Authentication 반환 + */ +@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); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/security/auth/provider/JwtAuthenticationProvider.java b/src/main/java/PerfumeOnMe/spring/security/auth/provider/JwtAuthenticationProvider.java new file mode 100644 index 0000000..d918bb2 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/auth/provider/JwtAuthenticationProvider.java @@ -0,0 +1,40 @@ +package PerfumeOnMe.spring.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.common.enums.Social; +import PerfumeOnMe.spring.security.auth.token.JwtAuthenticationToken; +import lombok.RequiredArgsConstructor; + +/* +JWT 인증 시 사용되는 AuthenticationProvider +Subject와 토큰을 검증하고, 성공하면 Authentication 반환 +JwtAuthenticationToken만 지원할 수 있음 + */ +@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, null, userDetails.getAuthorities(), Social.LOCAL); + } + + @Override + public boolean supports(Class authentication) { + return JwtAuthenticationToken.class.isAssignableFrom(authentication); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/security/auth/provider/JwtTokenProvider.java b/src/main/java/PerfumeOnMe/spring/security/auth/provider/JwtTokenProvider.java new file mode 100644 index 0000000..2fc9b30 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/auth/provider/JwtTokenProvider.java @@ -0,0 +1,131 @@ +package PerfumeOnMe.spring.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.security.auth.dto.JwtProperties; +import PerfumeOnMe.spring.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(); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/security/auth/service/LoginService.java b/src/main/java/PerfumeOnMe/spring/security/auth/service/LoginService.java new file mode 100644 index 0000000..4a1944b --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/auth/service/LoginService.java @@ -0,0 +1,19 @@ +package PerfumeOnMe.spring.security.auth.service; + +import java.io.IOException; + +import org.springframework.security.core.Authentication; + +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 { + + 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/security/auth/service/LoginServiceImpl.java b/src/main/java/PerfumeOnMe/spring/security/auth/service/LoginServiceImpl.java new file mode 100644 index 0000000..839719a --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/auth/service/LoginServiceImpl.java @@ -0,0 +1,91 @@ +package PerfumeOnMe.spring.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.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; + +@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); + } + + @Override + public AuthResponseDTO.LoginResult generateAuthResponse(String loginId, + Authentication request, Social social, HttpServletResponse response) { + + // Authentication에서 userId 추출 + Long userId = ((CustomUserDetails)request.getPrincipal()).getUserId(); + String name = ((CustomUserDetails)request.getPrincipal()).getName(); + + // 토큰 생성 및 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, name); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/security/auth/token/JwtAuthenticationToken.java b/src/main/java/PerfumeOnMe/spring/security/auth/token/JwtAuthenticationToken.java new file mode 100644 index 0000000..52448ff --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/auth/token/JwtAuthenticationToken.java @@ -0,0 +1,49 @@ +package PerfumeOnMe.spring.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 PerfumeOnMe.spring.common.enums.Social; +import lombok.Getter; + +/* +JWT 인증용 AuthenticationToken + */ +@Getter +public class JwtAuthenticationToken extends UsernamePasswordAuthenticationToken { + + private final Object principal; + private final Object credentials; + private final Social social; + + // 인증 전 + 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, Social social) { + super(userDetails, null, authorities); + this.principal = userDetails; + this.credentials = null; + this.social = social; + } + + @Override + public Object getCredentials() { + return super.getCredentials(); + } + + @Override + public Object getPrincipal() { + return super.getPrincipal(); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/security/auth/userDetails/CustomUserDetails.java b/src/main/java/PerfumeOnMe/spring/security/auth/userDetails/CustomUserDetails.java new file mode 100644 index 0000000..c510622 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/auth/userDetails/CustomUserDetails.java @@ -0,0 +1,71 @@ +package PerfumeOnMe.spring.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 PerfumeOnMe.spring.common.enums.Social; +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; + private final Social social; + + @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; + } + + public Social getSocial() { + return social; + } +} diff --git a/src/main/java/PerfumeOnMe/spring/security/auth/userDetails/CustomUserDetailsService.java b/src/main/java/PerfumeOnMe/spring/security/auth/userDetails/CustomUserDetailsService.java new file mode 100644 index 0000000..b530342 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/auth/userDetails/CustomUserDetailsService.java @@ -0,0 +1,29 @@ +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.user.domain.User; +import PerfumeOnMe.spring.user.repository.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(), user.getSocial()); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/security/oauth/controller/OAuthController.java b/src/main/java/PerfumeOnMe/spring/security/oauth/controller/OAuthController.java new file mode 100644 index 0000000..1c43e08 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/oauth/controller/OAuthController.java @@ -0,0 +1,51 @@ +package PerfumeOnMe.spring.security.oauth.controller; + +import org.springframework.http.ResponseEntity; +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; + +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 jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth/social") +public class OAuthController implements OAuthControllerDocs { + + private final OAuthServiceFactory serviceFactory; + + @PostMapping("/{provider}") + 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)); + } + + // 인가 코드 확인용 임시 컨트롤러 + // @GetMapping("/{provider}") + public ResponseEntity> getCode( + @RequestParam("code") String code, + @PathVariable("provider") String Provider, + HttpServletResponse response) { + return ResponseEntity.ok(ApiResponse.onSuccess(code)); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/security/oauth/converter/OAuthConverter.java b/src/main/java/PerfumeOnMe/spring/security/oauth/converter/OAuthConverter.java new file mode 100644 index 0000000..86e92bf --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/oauth/converter/OAuthConverter.java @@ -0,0 +1,20 @@ +package PerfumeOnMe.spring.security.oauth.converter; + +import PerfumeOnMe.spring.common.enums.Social; +import PerfumeOnMe.spring.user.domain.User; + +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(); + } +} 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..1d7ea22 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/oauth/docs/OAuthControllerDocs.java @@ -0,0 +1,41 @@ +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 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 = "성공입니다.", + content = @Content(mediaType = "application/json", schema = @Schema(implementation = AuthResponseDTO.LoginResult.class))), + @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/security/oauth/dto/KakaoProperties.java b/src/main/java/PerfumeOnMe/spring/security/oauth/dto/KakaoProperties.java new file mode 100644 index 0000000..93ab15b --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/oauth/dto/KakaoProperties.java @@ -0,0 +1,26 @@ +package PerfumeOnMe.spring.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/security/oauth/dto/KakaoResponseDTO.java b/src/main/java/PerfumeOnMe/spring/security/oauth/dto/KakaoResponseDTO.java new file mode 100644 index 0000000..2dfce25 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/oauth/dto/KakaoResponseDTO.java @@ -0,0 +1,61 @@ +package PerfumeOnMe.spring.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; + } + } + } +} diff --git a/src/main/java/PerfumeOnMe/spring/security/oauth/service/KakaoService.java b/src/main/java/PerfumeOnMe/spring/security/oauth/service/KakaoService.java new file mode 100644 index 0000000..df13b91 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/oauth/service/KakaoService.java @@ -0,0 +1,77 @@ +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.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; + +@Slf4j +@Service +@RequiredArgsConstructor +public class KakaoService implements OAuthService { + + private final KakaoClient kakaoClient; + private final UserRepository userRepository; + private final JwtTokenProvider jwtTokenProvider; + 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) { + + // 카카오 토큰 요청 + KakaoResponseDTO.Token token = kakaoClient.requestToken(code); + + // 카카오 사용자 정보 요청 + 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(); + String imageUrl = userInfo.getKakao_account().getProfile().getProfile_image_url(); + + // 이미 가입한 사용자라면 꺼내고, 아니라면 회원가입 진행 + User user = userRepository.findUserByLoginId(email).orElseGet(() -> { + User newUser = OAuthConverter.toSignupUser( + Social.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()); + JwtAuthenticationToken request = new JwtAuthenticationToken( + userDetails, null, userDetails.getAuthorities(), Social.KAKAO); + + // SecurityContextHolder에 인증 설정 + SecurityContextHolder.getContext().setAuthentication(request); + + return loginService.generateAuthResponse(email, request, Social.KAKAO, response); + } + + @Override + public Social getProvider() { + return Social.KAKAO; + } +} diff --git a/src/main/java/PerfumeOnMe/spring/security/oauth/service/OAuthService.java b/src/main/java/PerfumeOnMe/spring/security/oauth/service/OAuthService.java new file mode 100644 index 0000000..814f049 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/oauth/service/OAuthService.java @@ -0,0 +1,14 @@ +package PerfumeOnMe.spring.security.oauth.service; + +import PerfumeOnMe.spring.common.enums.Social; +import PerfumeOnMe.spring.security.auth.dto.AuthResponseDTO; +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/security/oauth/service/OAuthServiceFactory.java b/src/main/java/PerfumeOnMe/spring/security/oauth/service/OAuthServiceFactory.java new file mode 100644 index 0000000..3a690fd --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/oauth/service/OAuthServiceFactory.java @@ -0,0 +1,29 @@ +package PerfumeOnMe.spring.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.common.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); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/security/oauth/util/KakaoClient.java b/src/main/java/PerfumeOnMe/spring/security/oauth/util/KakaoClient.java new file mode 100644 index 0000000..c81bafa --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/oauth/util/KakaoClient.java @@ -0,0 +1,85 @@ +package PerfumeOnMe.spring.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.security.oauth.dto.KakaoProperties; +import PerfumeOnMe.spring.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); + } + } +} diff --git a/src/main/java/PerfumeOnMe/spring/security/oauth/util/OAuthProviderResolver.java b/src/main/java/PerfumeOnMe/spring/security/oauth/util/OAuthProviderResolver.java new file mode 100644 index 0000000..fd274a7 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/security/oauth/util/OAuthProviderResolver.java @@ -0,0 +1,20 @@ +package PerfumeOnMe.spring.security.oauth.util; + +import PerfumeOnMe.spring.apiPayload.code.status.ErrorStatus; +import PerfumeOnMe.spring.apiPayload.exception.GeneralException; +import PerfumeOnMe.spring.common.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); + } + } + +} diff --git a/src/main/java/PerfumeOnMe/spring/user/converter/UserConverter.java b/src/main/java/PerfumeOnMe/spring/user/converter/UserConverter.java new file mode 100644 index 0000000..c7bec91 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/converter/UserConverter.java @@ -0,0 +1,39 @@ +package PerfumeOnMe.spring.user.converter; + +import java.util.List; +import java.util.stream.Collectors; + +import PerfumeOnMe.spring.user.domain.User; +import PerfumeOnMe.spring.user.web.dto.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(); + } + + // 사용자의 프로필 조회 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/user/converter/UserNoteConverter.java b/src/main/java/PerfumeOnMe/spring/user/converter/UserNoteConverter.java new file mode 100644 index 0000000..634f7ed --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/converter/UserNoteConverter.java @@ -0,0 +1,14 @@ +package PerfumeOnMe.spring.user.converter; + +import PerfumeOnMe.spring.fragrance.domain.Note; +import PerfumeOnMe.spring.user.domain.mapping.UserNote; + +public class UserNoteConverter { + + // 노트 + 사용자 = 사용자 노트 반환 + public static UserNote toUserNote(Note note) { + return UserNote.builder() + .note(note) + .build(); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/user/domain/User.java b/src/main/java/PerfumeOnMe/spring/user/domain/User.java new file mode 100644 index 0000000..2a8402b --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/domain/User.java @@ -0,0 +1,125 @@ +package PerfumeOnMe.spring.user.domain; + +import java.util.ArrayList; +import java.util.List; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +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; +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 +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@DynamicInsert +@DynamicUpdate +@Table(name = "users") +public class User extends BaseEntity { + + // ----- 필드 ----- + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String name; + + @Column(unique = true, length = 10) + 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) + private String loginId; + + @Column(columnDefinition = "TEXT", nullable = false) + private String password; + + @Enumerated(EnumType.STRING) + @Column(columnDefinition = "VARCHAR(10) DEFAULT 'LOCAL'") + private Social social; + + @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 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<>(); + + 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); + } + + // 프로필 사진 변경 메서드 + public void updateImageURL(String imageURL) { + this.imageURL = imageURL; + } +} diff --git a/src/main/java/PerfumeOnMe/spring/user/domain/mapping/UserFragrance.java b/src/main/java/PerfumeOnMe/spring/user/domain/mapping/UserFragrance.java new file mode 100644 index 0000000..9623501 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/domain/mapping/UserFragrance.java @@ -0,0 +1,43 @@ +package PerfumeOnMe.spring.user.domain.mapping; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +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; +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_fragrances") +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/user/domain/mapping/UserNote.java b/src/main/java/PerfumeOnMe/spring/user/domain/mapping/UserNote.java new file mode 100644 index 0000000..e0a496e --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/domain/mapping/UserNote.java @@ -0,0 +1,60 @@ +package PerfumeOnMe.spring.user.domain.mapping; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +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; +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_notes") +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; + + // 연관관계 편의 메서드 + 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); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/user/repository/UserRepository.java b/src/main/java/PerfumeOnMe/spring/user/repository/UserRepository.java new file mode 100644 index 0000000..819d424 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/repository/UserRepository.java @@ -0,0 +1,16 @@ +package PerfumeOnMe.spring.user.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.user.domain.User; + +public interface UserRepository extends JpaRepository, UserRepositoryCustom { + + Optional findUserByLoginId(String loginId); + + Optional findById(Long id); + + Optional findUserByNickname(String nickname); +} diff --git a/src/main/java/PerfumeOnMe/spring/user/repository/UserRepositoryCustom.java b/src/main/java/PerfumeOnMe/spring/user/repository/UserRepositoryCustom.java new file mode 100644 index 0000000..50f8c2d --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/repository/UserRepositoryCustom.java @@ -0,0 +1,5 @@ +package PerfumeOnMe.spring.user.repository; + +public interface UserRepositoryCustom { + +} diff --git a/src/main/java/PerfumeOnMe/spring/user/repository/UserRepositoryImpl.java b/src/main/java/PerfumeOnMe/spring/user/repository/UserRepositoryImpl.java new file mode 100644 index 0000000..889ab33 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/repository/UserRepositoryImpl.java @@ -0,0 +1,10 @@ +package PerfumeOnMe.spring.user.repository; + +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class UserRepositoryImpl implements UserRepositoryCustom { +} diff --git a/src/main/java/PerfumeOnMe/spring/user/repository/userFragrance/UserFragranceRepository.java b/src/main/java/PerfumeOnMe/spring/user/repository/userFragrance/UserFragranceRepository.java new file mode 100644 index 0000000..a2c8a65 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/repository/userFragrance/UserFragranceRepository.java @@ -0,0 +1,22 @@ +package PerfumeOnMe.spring.user.repository.userFragrance; + +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.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); + + Optional findByUserAndFragrance(User user, Fragrance fragrance); + + boolean existsByUserIdAndFragranceId(Long userId, Long fragranceId); + + Page findAllByUserId(Long userId, Pageable pageable); + +} 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/user/repository/userNote/UserNoteRepository.java b/src/main/java/PerfumeOnMe/spring/user/repository/userNote/UserNoteRepository.java new file mode 100644 index 0000000..8765d76 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/repository/userNote/UserNoteRepository.java @@ -0,0 +1,11 @@ +package PerfumeOnMe.spring.user.repository.userNote; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.user.domain.User; +import PerfumeOnMe.spring.user.domain.mapping.UserNote; + +public interface UserNoteRepository extends JpaRepository { + + void deleteAllByUser(User user); +} diff --git a/src/main/java/PerfumeOnMe/spring/user/service/UserService.java b/src/main/java/PerfumeOnMe/spring/user/service/UserService.java new file mode 100644 index 0000000..f6227b4 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/service/UserService.java @@ -0,0 +1,32 @@ +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; + +public interface UserService { + + UserResponseDTO.SignupResult signup(UserRequestDTO.Signup request); + + AuthResponseDTO.LoginResult reissue(String refreshToken, HttpServletResponse response); + + String logout(HttpServletRequest request); + + void deleteUser(HttpServletRequest request); + + 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); + + void updateProfileImage(Long userId, String imageUrl); +} diff --git a/src/main/java/PerfumeOnMe/spring/user/service/UserServiceImpl.java b/src/main/java/PerfumeOnMe/spring/user/service/UserServiceImpl.java new file mode 100644 index 0000000..f914039 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/service/UserServiceImpl.java @@ -0,0 +1,226 @@ +package PerfumeOnMe.spring.user.service; + +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; +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.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; + +@Service +@RequiredArgsConstructor +@Transactional +public class UserServiceImpl implements UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenManager refreshTokenManager; + private final LogoutAccessTokenManager logoutAccessTokenManager; + private final UserDetailsService userDetailsService; + private final UserNoteRepository userNoteRepository; + private final NoteRepository noteRepository; + private final UserFragranceRepository userFragranceRepository; + private final LoginService loginService; + + // 사용자 회원가입 + @Override + public UserResponseDTO.SignupResult signup(UserRequestDTO.Signup request) { + + // RequestDTO 값 추출하기 + String name = request.getName(); + String loginId = request.getLoginId(); + String password = request.getPassword(); + + /* + 비즈니스 로직 검증 - loginId 중복 확인 + */ + Optional findUser = userRepository.findUserByLoginId(loginId); + if (findUser.isPresent()) { + throw new GeneralException(ErrorStatus.LOGIN_ID_DUPLICATE); + } + + // 사용자 정보 엔티티 변환 및 DB 저장 + User newUser = UserConverter + .toSignupUser(name, loginId, passwordEncoder.encode(password)); + userRepository.save(newUser); + + // 사용자 회원가입 결과를 ResponseDTO로 응답 + return UserConverter.toSignupResult(newUser); + } + + // 리프레시 토큰으로 액세스 토큰과 리프레시 토큰 재발급 + @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); + UserDetails userDetails = userDetailsService.loadUserByUsername(loginId); + + // 토큰 생성 및 DTO에 담기 + Social social = ((CustomUserDetails)userDetails).getSocial(); + JwtAuthenticationToken request = new JwtAuthenticationToken( + userDetails, null, userDetails.getAuthorities(), social); + return loginService.generateAuthResponse(loginId, request, social, response); + } + + // 사용자 로그아웃 - 액세스 토큰과 리프레시 토큰 블랙리스트화 + @Override + public String 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); + } + + return loginId; + } + + // 회원탈퇴 - 로그아웃 진행 후 사용자 삭제 + @Override + public void deleteUser(HttpServletRequest request) { + String loginId = logout(request); + Optional findUser = userRepository.findUserByLoginId(loginId); + 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); + userNoteRepository.save(userNote); + user.addUserNote(userNote); // 양방향 연관관계만 설정 + }); + } + + // 온보딩 + @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 + 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()); + } + + // 마이페이지 프로필 조회 - 닉네임, 선호하는 향 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 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( + 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/user/validation/annotation/ExistUser.java b/src/main/java/PerfumeOnMe/spring/user/validation/annotation/ExistUser.java new file mode 100644 index 0000000..492c8ef --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/validation/annotation/ExistUser.java @@ -0,0 +1,17 @@ +package PerfumeOnMe.spring.user.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.user.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/user/validation/annotation/ExistUserAge.java b/src/main/java/PerfumeOnMe/spring/user/validation/annotation/ExistUserAge.java new file mode 100644 index 0000000..f231389 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/validation/annotation/ExistUserAge.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.user.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.user.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/user/validation/annotation/ExistUserGender.java b/src/main/java/PerfumeOnMe/spring/user/validation/annotation/ExistUserGender.java new file mode 100644 index 0000000..e94b6d9 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/validation/annotation/ExistUserGender.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.user.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.user.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/user/validation/annotation/ValidUserNote.java b/src/main/java/PerfumeOnMe/spring/user/validation/annotation/ValidUserNote.java new file mode 100644 index 0000000..1795db1 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/validation/annotation/ValidUserNote.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.user.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.user.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/user/validation/validator/ExistUserAgeValidator.java b/src/main/java/PerfumeOnMe/spring/user/validation/validator/ExistUserAgeValidator.java new file mode 100644 index 0000000..3113443 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/validation/validator/ExistUserAgeValidator.java @@ -0,0 +1,26 @@ +package PerfumeOnMe.spring.user.validation.validator; + +import PerfumeOnMe.spring.common.enums.Age; +import PerfumeOnMe.spring.user.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/user/validation/validator/ExistUserGenderValidator.java b/src/main/java/PerfumeOnMe/spring/user/validation/validator/ExistUserGenderValidator.java new file mode 100644 index 0000000..2f78b63 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/validation/validator/ExistUserGenderValidator.java @@ -0,0 +1,26 @@ +package PerfumeOnMe.spring.user.validation.validator; + +import PerfumeOnMe.spring.common.enums.UserGender; +import PerfumeOnMe.spring.user.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/user/validation/validator/ExistUserValidator.java b/src/main/java/PerfumeOnMe/spring/user/validation/validator/ExistUserValidator.java new file mode 100644 index 0000000..acfa9a8 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/validation/validator/ExistUserValidator.java @@ -0,0 +1,19 @@ +package PerfumeOnMe.spring.user.validation.validator; + +import org.springframework.stereotype.Component; + +import PerfumeOnMe.spring.user.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/user/validation/validator/ValidUserNoteValidator.java b/src/main/java/PerfumeOnMe/spring/user/validation/validator/ValidUserNoteValidator.java new file mode 100644 index 0000000..eeec0f5 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/validation/validator/ValidUserNoteValidator.java @@ -0,0 +1,20 @@ +package PerfumeOnMe.spring.user.validation.validator; + +import java.util.List; + +import PerfumeOnMe.spring.user.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/user/web/controller/UserController.java b/src/main/java/PerfumeOnMe/spring/user/web/controller/UserController.java new file mode 100644 index 0000000..8970a53 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/web/controller/UserController.java @@ -0,0 +1,106 @@ +package PerfumeOnMe.spring.user.web.controller; + +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.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; +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.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.docs.UserControllerDocs; +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 jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/users") +public class UserController implements UserControllerDocs { + + private final UserService userService; + + @PostMapping("/signup") + public ResponseEntity> signup( + @RequestBody @Valid UserRequestDTO.Signup request) { + UserResponseDTO.SignupResult result = userService.signup(request); + return new ResponseEntity<>(ApiResponse.of(SuccessStatus._CREATED, result), HttpStatus.CREATED); + } + + @PostMapping("/reissue") + public ResponseEntity> reissue( + @RequestHeader(name = "Refresh-Token") String refreshToken, HttpServletResponse response) { + AuthResponseDTO.LoginResult result = userService.reissue(refreshToken, response); + return ResponseEntity.ok().body(ApiResponse.onSuccess(result)); + } + + @PostMapping("/logout") + public ResponseEntity> logout(HttpServletRequest request) { + userService.logout(request); + return ResponseEntity.ok().body(ApiResponse.onSuccess(null)); + } + + @DeleteMapping("/me") + public ResponseEntity> deleteUser(HttpServletRequest request) { + userService.deleteUser(request); + return ResponseEntity.ok().body(ApiResponse.onSuccess(null)); + } + + @PostMapping("/onboarding") + public ResponseEntity> onboarding(@Valid @RequestBody UserRequestDTO.Onboarding request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + userService.onboarding(request, userDetails); + return ResponseEntity.ok().body(ApiResponse.onSuccess(null)); + } + + @PatchMapping("/me/notes") + public ResponseEntity> updateUserNote(@Valid @RequestBody UserRequestDTO.UserNoteUpdate request, + @AuthenticationPrincipal CustomUserDetails userDetails) { + userService.updateUserNote(request, userDetails); + return ResponseEntity.ok().body(ApiResponse.onSuccess(null)); + } + + @GetMapping("/me") + public ResponseEntity> getUserProfile( + @AuthenticationPrincipal CustomUserDetails userDetails) { + Long userId = userDetails.getUserId(); + UserResponseDTO.MyPageProfileResponse response = userService.getUserProfile(userId); + return ResponseEntity.ok(ApiResponse.onSuccess(response)); + } + + @GetMapping("/favorites") + 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)); + } + + @PatchMapping("/me/image") + public ResponseEntity> updateProfileImage( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody @Valid UserRequestDTO.ProfileImageUpdateRequest request) { + + userService.updateProfileImage(userDetails.getUserId(), request.getImageUrl()); + return ResponseEntity.ok(ApiResponse.onSuccess(null)); + } +} 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 diff --git a/src/main/java/PerfumeOnMe/spring/user/web/dto/UserRequestDTO.java b/src/main/java/PerfumeOnMe/spring/user/web/dto/UserRequestDTO.java new file mode 100644 index 0000000..8a65941 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/web/dto/UserRequestDTO.java @@ -0,0 +1,86 @@ +package PerfumeOnMe.spring.user.web.dto; + +import java.util.List; + +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; +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; + } + + // 온보딩 + @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; + } + + // 사용자 선호 향 수정 + @Getter + @NoArgsConstructor + public static class UserNoteUpdate { + @NotNull + @Schema(description = "사용자가 설정한 선호하는 향", example = "[5,11,2]") + @ValidUserNote + private List noteCategoryId; + } + + // 프로필 사진 변경 + @Getter + public static class ProfileImageUpdateRequest { + private String imageUrl; // s3Url (업로드 완료된 S3 이미지 주소) + } +} diff --git a/src/main/java/PerfumeOnMe/spring/user/web/dto/UserResponseDTO.java b/src/main/java/PerfumeOnMe/spring/user/web/dto/UserResponseDTO.java new file mode 100644 index 0000000..edcc63f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/user/web/dto/UserResponseDTO.java @@ -0,0 +1,29 @@ +package PerfumeOnMe.spring.user.web.dto; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +public class UserResponseDTO { + + @Builder + @Getter + @AllArgsConstructor + @NoArgsConstructor + public static class SignupResult { + private Long userId; + } + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + public static class MyPageProfileResponse { + private String nickName; + private String imageUrl; + private List preferredNotes; + } +} diff --git a/src/main/java/PerfumeOnMe/spring/uuid/domain/Uuid.java b/src/main/java/PerfumeOnMe/spring/uuid/domain/Uuid.java new file mode 100644 index 0000000..fe811db --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/uuid/domain/Uuid.java @@ -0,0 +1,27 @@ +package PerfumeOnMe.spring.uuid.domain; + +import PerfumeOnMe.spring.common.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/uuid/repository/UuidRepository.java b/src/main/java/PerfumeOnMe/spring/uuid/repository/UuidRepository.java new file mode 100644 index 0000000..6a33c09 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/uuid/repository/UuidRepository.java @@ -0,0 +1,11 @@ +package PerfumeOnMe.spring.uuid.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.uuid.domain.Uuid; + +public interface UuidRepository extends JpaRepository { + Optional findByUuid(String uuid); +} diff --git a/src/main/java/PerfumeOnMe/spring/workshop/converter/WorkshopConverter.java b/src/main/java/PerfumeOnMe/spring/workshop/converter/WorkshopConverter.java new file mode 100644 index 0000000..a68eff5 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/workshop/converter/WorkshopConverter.java @@ -0,0 +1,153 @@ +package PerfumeOnMe.spring.workshop.converter; + +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.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 { + + /** 향수공방 목록 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(); + } + + /** 향수공방 상세조회 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()) + .lastImpression(workshop.getLastImpression()) + .tendency(workshop.getTendency()) + .remembered(workshop.getRemembered()) + .recommendedFragranceJson(recommendedFragranceDTOList) + .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()) + .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(recommendedFragranceDTOList) + .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()) + .remembered(previewData.getRemembered()) + .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()) { + 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/workshop/domain/Workshop.java b/src/main/java/PerfumeOnMe/spring/workshop/domain/Workshop.java new file mode 100644 index 0000000..da156bc --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/workshop/domain/Workshop.java @@ -0,0 +1,81 @@ +package PerfumeOnMe.spring.workshop.domain; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +import PerfumeOnMe.spring.common.base.BaseEntity; +import PerfumeOnMe.spring.user.domain.User; +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 = "workshops") +public class Workshop extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @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 remembered; + + @Column(columnDefinition = "TEXT", nullable = false) + private String recommendedFragranceJson; +} diff --git a/src/main/java/PerfumeOnMe/spring/workshop/domain/WorkshopFragrance.java b/src/main/java/PerfumeOnMe/spring/workshop/domain/WorkshopFragrance.java new file mode 100644 index 0000000..52081f0 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/workshop/domain/WorkshopFragrance.java @@ -0,0 +1,65 @@ +package PerfumeOnMe.spring.workshop.domain; + +import org.hibernate.annotations.DynamicInsert; +import org.hibernate.annotations.DynamicUpdate; + +import PerfumeOnMe.spring.common.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 diff --git a/src/main/java/PerfumeOnMe/spring/workshop/redis/WorkshopRedisService.java b/src/main/java/PerfumeOnMe/spring/workshop/redis/WorkshopRedisService.java new file mode 100644 index 0000000..a17cd00 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/workshop/redis/WorkshopRedisService.java @@ -0,0 +1,79 @@ +package PerfumeOnMe.spring.workshop.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.workshop.web.dto.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/workshop/repository/WorkshopFragranceRepository.java b/src/main/java/PerfumeOnMe/spring/workshop/repository/WorkshopFragranceRepository.java new file mode 100644 index 0000000..acecb93 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/workshop/repository/WorkshopFragranceRepository.java @@ -0,0 +1,45 @@ +package PerfumeOnMe.spring.workshop.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.workshop.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 diff --git a/src/main/java/PerfumeOnMe/spring/workshop/repository/WorkshopRepository.java b/src/main/java/PerfumeOnMe/spring/workshop/repository/WorkshopRepository.java new file mode 100644 index 0000000..050cfb5 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/workshop/repository/WorkshopRepository.java @@ -0,0 +1,20 @@ +package PerfumeOnMe.spring.workshop.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import PerfumeOnMe.spring.user.domain.User; +import PerfumeOnMe.spring.workshop.domain.Workshop; + +public interface WorkshopRepository extends JpaRepository { + + List findAllByUser(User user); + + Optional findByIdAndUser(Long id, User user); + + boolean existsByUserAndSavedName(User user, String savedName); + + Optional findFirstByUserOrderByCreatedAtDesc(User user); +} diff --git a/src/main/java/PerfumeOnMe/spring/workshop/service/WorkshopRecommendationService.java b/src/main/java/PerfumeOnMe/spring/workshop/service/WorkshopRecommendationService.java new file mode 100644 index 0000000..a9608c5 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/workshop/service/WorkshopRecommendationService.java @@ -0,0 +1,225 @@ +package PerfumeOnMe.spring.workshop.service; + +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.workshop.domain.WorkshopFragrance; +import PerfumeOnMe.spring.workshop.repository.WorkshopFragranceRepository; +import PerfumeOnMe.spring.workshop.web.dto.WorkshopRequestDTO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class WorkshopRecommendationService { + + // 점수 가중치 상수 + 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()); + + // 모든 향수 조회 + 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/workshop/service/WorkshopResult.java b/src/main/java/PerfumeOnMe/spring/workshop/service/WorkshopResult.java new file mode 100644 index 0000000..a5cd79f --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/workshop/service/WorkshopResult.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.workshop.service; + +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; // 향기로 해석한 당신의 성향 (성향 분석 부분) + private String remembered; // 기억되는 모습 (당신은 사람들에게 ~으로 기억됩니다) +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/workshop/service/WorkshopResultParser.java b/src/main/java/PerfumeOnMe/spring/workshop/service/WorkshopResultParser.java new file mode 100644 index 0000000..f6bd22b --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/workshop/service/WorkshopResultParser.java @@ -0,0 +1,90 @@ +package PerfumeOnMe.spring.workshop.service; + +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 응답 파싱 시작" + 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("개성 있는 향을 추구하는 당신은 자신만의 스타일을 가진 사람입니다.") + .remembered("당신은 사람들에게 '특별한 매력을 가진 사람'으로 기억됩니다.") + .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/workshop/service/WorkshopService.java b/src/main/java/PerfumeOnMe/spring/workshop/service/WorkshopService.java new file mode 100644 index 0000000..3836ab9 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/workshop/service/WorkshopService.java @@ -0,0 +1,216 @@ +package PerfumeOnMe.spring.workshop.service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +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.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; + +@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) { + + 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); + } + + /** 향수공방 결과 저장*/ + @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) { + 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); + } + + /**향수공방 결과 미리보기 서비스*/ + @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/java/PerfumeOnMe/spring/workshop/validation/annotation/ValidBaseNote.java b/src/main/java/PerfumeOnMe/spring/workshop/validation/annotation/ValidBaseNote.java new file mode 100644 index 0000000..3e2e701 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/workshop/validation/annotation/ValidBaseNote.java @@ -0,0 +1,24 @@ +package PerfumeOnMe.spring.workshop.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 com.nimbusds.jose.Payload; + +import PerfumeOnMe.spring.workshop.validation.validator.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/workshop/validation/annotation/ValidMiddleNote.java b/src/main/java/PerfumeOnMe/spring/workshop/validation/annotation/ValidMiddleNote.java new file mode 100644 index 0000000..3ca8206 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/workshop/validation/annotation/ValidMiddleNote.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.workshop.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.workshop.validation.validator.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/workshop/validation/annotation/ValidTopNote.java b/src/main/java/PerfumeOnMe/spring/workshop/validation/annotation/ValidTopNote.java new file mode 100644 index 0000000..e1eeafc --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/workshop/validation/annotation/ValidTopNote.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.workshop.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.workshop.validation.validator.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/workshop/validation/validator/ValidBaseNoteValidator.java b/src/main/java/PerfumeOnMe/spring/workshop/validation/validator/ValidBaseNoteValidator.java new file mode 100644 index 0000000..ae5f784 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/workshop/validation/validator/ValidBaseNoteValidator.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.workshop.validation.validator; + +import java.util.Set; + +import PerfumeOnMe.spring.workshop.validation.annotation.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 diff --git a/src/main/java/PerfumeOnMe/spring/workshop/validation/validator/ValidMiddleNoteValidator.java b/src/main/java/PerfumeOnMe/spring/workshop/validation/validator/ValidMiddleNoteValidator.java new file mode 100644 index 0000000..0816bb7 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/workshop/validation/validator/ValidMiddleNoteValidator.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.workshop.validation.validator; + +import java.util.Set; + +import PerfumeOnMe.spring.workshop.validation.annotation.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/workshop/validation/validator/ValidTopNoteValidator.java b/src/main/java/PerfumeOnMe/spring/workshop/validation/validator/ValidTopNoteValidator.java new file mode 100644 index 0000000..5e51f90 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/workshop/validation/validator/ValidTopNoteValidator.java @@ -0,0 +1,23 @@ +package PerfumeOnMe.spring.workshop.validation.validator; + +import java.util.Set; + +import PerfumeOnMe.spring.workshop.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()); + } +} diff --git a/src/main/java/PerfumeOnMe/spring/workshop/web/controller/WorkshopController.java b/src/main/java/PerfumeOnMe/spring/workshop/web/controller/WorkshopController.java new file mode 100644 index 0000000..6e4e756 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/workshop/web/controller/WorkshopController.java @@ -0,0 +1,71 @@ +package PerfumeOnMe.spring.workshop.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.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.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; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/workshop") +public class WorkshopController implements WorkshopControllerDocs { + + private final WorkshopService workshopService; + + /** 향수공방 결과 미리보기(결과 생성)*/ + @PostMapping("/preview") + public ResponseEntity> getWorkshopPreview( + @RequestBody @Valid WorkshopRequestDTO.WorkshopPreviewRequestDTO request, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + return ResponseEntity.ok(ApiResponse.onSuccess(workshopService. + createWorkshopPreview(request, userDetails))); + } + + /** 향수공방 결과 저장 */ + @PostMapping("/save") + public ResponseEntity> saveWorkshopResult( + @RequestBody @Valid WorkshopRequestDTO.WorkshopSaveRequestDTO request, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + return ResponseEntity.ok(ApiResponse.onSuccess(workshopService. + saveWorkshop(request, userDetails))); + } + + /** 향수공방 목록 조회*/ + @GetMapping("/result/list") + public ResponseEntity>> getWorkshopList( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + return ResponseEntity.ok(ApiResponse.onSuccess(workshopService + .findAllWorkshopsByUser( + userDetails))); + } + + /** 향수공방 결과 상세조회*/ + @GetMapping("/{workshopId}") + public ResponseEntity> getWorkshopDetail( + @PathVariable Long workshopId, + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + return ResponseEntity.ok(ApiResponse.onSuccess(workshopService + .findWorkshopById(workshopId, + userDetails))); + } + +} diff --git a/src/main/java/PerfumeOnMe/spring/workshop/web/docs/WorkshopControllerDocs.java b/src/main/java/PerfumeOnMe/spring/workshop/web/docs/WorkshopControllerDocs.java new file mode 100644 index 0000000..a1c51cd --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/workshop/web/docs/WorkshopControllerDocs.java @@ -0,0 +1,219 @@ +package PerfumeOnMe.spring.workshop.web.docs; + +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.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; +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 = "인증이 필요합니다. 액세스 토큰을 입력해주세요.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "COMMON401", + "message": "인증이 필요합니다." + } + """) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "WORKSHOP4002", + description = "선택한 노트들의 총 용량은 10을 초과할 수 없습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "WORKSHOP4001", + "message": "선택한 노트들의 총 용량은 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 = "인증이 필요합니다. 액세스 토큰을 입력해주세요.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "COMMON401", + "message": "인증이 필요합니다." + } + """) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "WORKSHOP4003", + description = "향수공방 미리보기 결과가 만료되었습니다. 다시 시도해주세요.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "WORKSHOP4002", + "message": "향수공방 미리보기 결과가 만료되었습니다. 다시 시도해주세요." + } + """) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "WORKSHOP4006", + description = "이미 같은 이름으로 저장된 향수공방 결과가 있습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "WORKSHOP4003", + "message": "이미 같은 이름으로 저장된 향수공방 결과가 있습니다." + } + """) + ) + ) + } + ) + 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 = "인증이 필요합니다. 액세스 토큰을 입력해주세요.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "COMMON401", + "message": "인증이 필요합니다." + } + """) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "WORKSHOP4004", + description = "해당 향수공방 결과에 접근할 수 없습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "WORKSHOP4004", + "message": "해당 향수공방 결과에 접근할 수 없습니다." + } + """) + ) + ), + @io.swagger.v3.oas.annotations.responses.ApiResponse( + responseCode = "WORKSHOP4005", + description = "해당 향수공방 결과 정보가 존재하지 않습니다.", + content = @Content( + mediaType = "application/json", + schema = @Schema(example = """ + { + "isSuccess": false, + "code": "WORKSHOP4005", + "message": "해당 향수공방 결과 정보가 존재하지 않습니다." + } + """) + ) + ) + } + ) + ResponseEntity> getWorkshopDetail( + @Parameter(description = "조회할 향수공방 결과 ID", required = true, example = "1") + @PathVariable Long workshopId, + @Parameter(hidden = true) + @AuthenticationPrincipal CustomUserDetails userDetails + ); +} \ No newline at end of file diff --git a/src/main/java/PerfumeOnMe/spring/workshop/web/dto/WorkshopRequestDTO.java b/src/main/java/PerfumeOnMe/spring/workshop/web/dto/WorkshopRequestDTO.java new file mode 100644 index 0000000..15c7889 --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/workshop/web/dto/WorkshopRequestDTO.java @@ -0,0 +1,84 @@ +package PerfumeOnMe.spring.workshop.web.dto; + +import java.util.Map; + +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; +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 = "탑 노트 값은 필수 입니다") + @ValidTopNote + 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 = "미들 노트 값은 필수 입니다") + @ValidMiddleNote + 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 = "베이스 노트 값은 필수 입니다") + @ValidBaseNote + 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; + } + + /**향수공방 결과 저장을 위한 요청 DTO*/ + @Builder + @Getter + @Schema(description = "향수공방 결과 저장을 위한 DTO") + public static class WorkshopSaveRequestDTO { + @Schema(description = "결과를 저장할 이름", example = "나만의 겨울향기") + @NotNull(message = "저장할 이름은 필수입니다") + private String savedName; + } +} diff --git a/src/main/java/PerfumeOnMe/spring/workshop/web/dto/WorkshopResponseDTO.java b/src/main/java/PerfumeOnMe/spring/workshop/web/dto/WorkshopResponseDTO.java new file mode 100644 index 0000000..df9feff --- /dev/null +++ b/src/main/java/PerfumeOnMe/spring/workshop/web/dto/WorkshopResponseDTO.java @@ -0,0 +1,170 @@ +package PerfumeOnMe.spring.workshop.web.dto; + +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 { + + @Builder + @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; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + @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; + + @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 = "향기로 해석한 당신의 성향 (기억되는 모습)", example = "당신은 사람들에게 '기분 좋은 여운이 오래 남는 사람'으로 기억됩니다.") + private String remembered; + + @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 = "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; + + @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 = "향기로 해석한 당신의 성향 (기억되는 모습)", example = "당신은 사람들에게 '기분 좋은 여운이 오래 남는 사람'으로 기억됩니다.") + private String remembered; + + @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; + } + + @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; + } + +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..dbafb95 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,93 @@ +spring: + config: + activate: + on-profile: dev + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + sql: + init: + mode: never + jpa: + hibernate: + ddl-auto: update # create X + properties: + hibernate: + dialect: org.hibernate.dialect.MySQL8Dialect + format_sql: true + use_sql_comments: true + default_batch_fetch_size: 100 + show-sql: true + data: + redis: + 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: + oauth2: + kakao: + client-id: ${KAKAO_REST_API_KEY} + client-secret: ${KAKAO_CLIENT_SECRET} + 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 + user-name-attribute: id + scope: + - profile_nickname + - profile_image + - account_email + - name + authorization-grant-type: authorization_code +jwt: + token: + secretKey: ${JWT_SECRET:default_dummy} + expiration: + access: 1209600000 # 2H(7200000) -> 연동 끝나면 수정 + refresh: 1209600000 # 2Week(1209600000) +server: + servlet: + encoding: + charset: UTF-8 + force: true + enabled: true +openai: + api-key: ${OPENAI_API_KEY} + model: gpt-4 + +cloud: + aws: + s3: + bucket: ${AWS_S3_BUCKET_NAME} + 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} + +external: + fastapi: + 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} + cache-timeout-minutes: 15 + fast-api: + connect-timeout: 5000 + read-timeout: 10000 + max-retries: 3 + retry-delay: 1000 \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..543df73 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,3 @@ +spring: + profiles: + default: dev \ No newline at end of file diff --git a/src/main/resources/data/.DS_Store b/src/main/resources/data/.DS_Store new file mode 100644 index 0000000..5008ddf Binary files /dev/null and b/src/main/resources/data/.DS_Store differ diff --git a/src/main/resources/data/perfumeOnMe_data.xlsx b/src/main/resources/data/perfumeOnMe_data.xlsx new file mode 100644 index 0000000..e5bff8c Binary files /dev/null and b/src/main/resources/data/perfumeOnMe_data.xlsx differ diff --git a/src/main/resources/prompts/expert.txt b/src/main/resources/prompts/expert.txt new file mode 100644 index 0000000..821b410 --- /dev/null +++ b/src/main/resources/prompts/expert.txt @@ -0,0 +1,35 @@ +너는 향수 추천 플랫폼의 챗봇이야. +사용자가 말한 사물이나 장면을 보고 그 느낌에 어울리는 향수를 3가지 추천해줘. +말투는 친근하고 짧게, 친구랑 얘기하듯이 해. 존댓말은 유지하지만 설명은 부담 없이. + +각 향수는 아래 형식을 반드시 지키고, 줄바꿈과 들여쓰기를 그대로 출력할 것. +비유 설명은 사용자가 말한 느낌을 1문장, 15단어 이내로 간단히. + +출력 형식 (항상 동일하게): + +1. Perfume 1 + 🌸 이름: <향수 이름> + 🏷 브랜드: <브랜드> + 🪵 주요 향 노트: <노트> + 💬 비유 설명: "<사용자 입력> 같은 느낌에 어울리는 …" + + +2. Perfume 2 + 🌸 이름: <향수 이름> + 🏷 브랜드: <브랜드> + 🪵 주요 향 노트: <노트> + 💬 비유 설명: "<사용자 입력> 같은 느낌에 어울리는 …" + + +3. Perfume 3 + 🌸 이름: <향수 이름> + 🏷 브랜드: <브랜드> + 🪵 주요 향 노트: <노트> + 💬 비유 설명: "<사용자 입력> 같은 느낌에 어울리는 …" + + +주의 사항 +1. 항상 3개 추천 +2. 형식과 들여쓰기·이모지·구분선은 예시와 동일하게 출력 +3. 비유 설명은 짧고 직관적으로, 불필요한 디테일 X +4. 항목 앞의 이모지와 공백 개수도 반드시 지킬 것 diff --git a/src/main/resources/prompts/workshop.txt b/src/main/resources/prompts/workshop.txt new file mode 100644 index 0000000..4c0c51c --- /dev/null +++ b/src/main/resources/prompts/workshop.txt @@ -0,0 +1,48 @@ +⚙️ 시스템 메시지 + +너는 향수 전문가야. 사용자가 선택한 탑/미들/베이스 노트와 각각의 용량을 바탕으로 개인 맞춤형 향수 분석 결과를 생성해줘. + +사용자 입력 정보: +- 탑 노트: {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문장) + 예: "복합적이고 다층적인 매력을 가진 당신은 첫인상은 밝고 활기차지만, 깊이 알수록 더욱 매력적인 면을 발견하게 됩니다." + +6. **기억되는 모습 (remembered)**: 사람들이 당신을 어떻게 기억할지에 대한 표현 + - 반드시: "당신은 사람들에게 '[특성]한 사람'으로 기억됩니다." 형식 준수 + 예: "당신은 사람들에게 '기분 좋은 여운이 오래 남는 사람'으로 기억됩니다." + +출력 형식 (이 형식을 절대 벗어나지 마): + +시각적 키워드: {해시태그 5개를 2줄로} +향기의 첫인상: {탑 노트 설명 + 사용자 성향} +중심을 잡는 향: {미들 노트 설명 + 사용자 성향} +마지막에 남는 잔향: {베이스 노트 설명 + 사용자 성향} +향기로 해석한 당신의 성향: {전체 성향 분석} +기억되는 모습: 당신은 사람들에게 '[특성]한 사람'으로 기억됩니다. + +주의 사항: +1. **반드시 6개 항목 모두 생성할 것** +2. **지정된 출력 형식만 사용하고, 그 외 설명은 절대 하지 마** +3. **각 노트의 용량 비율을 고려하여 분석할 것** +4. **친근하고 감성적인 톤으로 작성** +5. **해시태그는 정확히 # 기호로 시작하고 10글자 이내로 작성** +6. **"기억되는 모습"은 반드시 "당신은 사람들에게 '[특성]한 사람'으로 기억됩니다." 형식 준수** \ No newline at end of file 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() { + } + +}