diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..43f56b8 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,14 @@ +language: "ko-KR" +early_access: false +reviews: + profile: "chill" + request_changes_workflow: false + high_level_summary: true + poem: true + review_status: true + collapse_walkthrough: false + auto_review: + enabled: true + drafts: false +chat: + auto_reply: true 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/bug_template.md b/.github/ISSUE_TEMPLATE/bug_template.md new file mode 100644 index 0000000..5fdc9ac --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_template.md @@ -0,0 +1,19 @@ +--- +name: Bug Report Template +about: '버그 리포트 이슈 템플릿 ' +title: 'fix: ' +labels: ['🐛bug'] +assignees: '' + +--- + +## 🐛 어떤 버그인가요? + + + +## 🤷‍♂️ 어떤 상황에서 발생한 버그인가요? + + + +## 🤔 예상 결과 + \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_template.md b/.github/ISSUE_TEMPLATE/feature_template.md new file mode 100644 index 0000000..9193368 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_template.md @@ -0,0 +1,14 @@ +--- +name: feature_template +about: '기능 추가 템플릿' +title: 'feat: ' +labels: ['✨feature'] +assignees: '' + +--- + +## 📌 어떤 기능인가요? + + +## 📝 작업 상세 내용 +- [ ] TODO \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/refactor_template.md b/.github/ISSUE_TEMPLATE/refactor_template.md new file mode 100644 index 0000000..380e4ae --- /dev/null +++ b/.github/ISSUE_TEMPLATE/refactor_template.md @@ -0,0 +1,15 @@ +--- +name: Refactor Template +about: '리팩토링 템플릿' +title: 'refactor: ' +labels: ['♻️refactor'] +assignees: '' + +--- + +## 📌 어떤 기능인가요? + + + +## 📝 작업 상세 내용 +- [ ] TODO \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..09ab3fa --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,14 @@ +## #️⃣ 연관된 이슈 + +- + +## 📝 작업 내용 + + +- + +## 📸 스크린샷 + + +## 💬 리뷰 요구사항 + diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..8cde663 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,99 @@ +name: CI/CD Pipeline + +on: + push: + branches: [ "main", "develop" ] + +jobs: + build-docker-image: + name: Build docker image + runs-on: ubuntu-latest + + steps: + # 1. 코드 체크아웃 + - name: Checkout code + uses: actions/checkout@v3 + + # 2. Java 환경 설정 + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + + # 3. Gradle Wrapper 실행 권한 추가 + - name: Add execute permission for Gradle Wrapper + run: chmod +x ./gradlew + + # Gradle 캐시 설정 + - 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: Ensure resources directory exists + run: mkdir -p src/main/resources + + # application.yml 파일 생성 + - name: Create application.yml + run: | + cd ./src/main/resources + touch ./application.yml + echo "${{ secrets.APPLICATION_YML }}" > ./application.yml + + # 4. 프로젝트 빌드 + - name: Build with Gradle + uses: gradle/gradle-build-action@v2 + with: + arguments: clean bootJar -Pspring.profiles.active=prod + + # 5. 빌드 결과 확인 (디버깅용) + - name: Check build output + run: ls -la build/libs/ + + # 6. Docker 이미지 빌드 + - name: Build Docker Image + run: docker build -t ${{ secrets.DOCKER_USERNAME }}/webeye-server:latest . + + # 7. Docker Hub 로그인 + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + # 8. Docker 이미지 푸시 + - name: Push Docker Image + run: docker push ${{ secrets.DOCKER_USERNAME }}/webeye-server:latest + + deploy-pipeline: + needs: build-docker-image + runs-on: ubuntu-latest + + steps: + # 1. 코드 체크아웃 + - name: Checkout code + uses: actions/checkout@v3 + + # 2. NCP 배포 + - name: Deploy to NCP + uses: appleboy/ssh-action@v0.1.10 + with: + host: ${{ secrets.NCP_HOST }} + username: ${{ secrets.NCP_USER }} + key: ${{ secrets.NCP_PRIVATE_KEY }} + script: | + docker pull ${{ secrets.DOCKER_USERNAME }}/webeye-server:latest + docker stop my-app || true + docker rm my-app || true + docker run -d -p 8080:8080 --name my-app \ + -e TZ=Asia/Seoul \ + -e SPRING_DATASOURCE_URL=jdbc:mysql://${{ secrets.DB_ENDPOINT }}:3306/webeye \ + -e SPRING_DATASOURCE_USERNAME=${{ secrets.DB_USERNAME }} \ + -e SPRING_DATASOURCE_PASSWORD=${{ secrets.DB_PASSWORD }} \ + ${{ secrets.DOCKER_USERNAME }}/webeye-server:latest \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ac2fa6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,51 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### application.yml ### +application.yml + +### 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/ + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +### Gradle ### +.gradle +**/build/ +!src/**/build/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..72fe5a1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,6 @@ +FROM openjdk:21-jdk-slim +WORKDIR /app +RUN ln -sf /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \ + echo "Asia/Seoul" > /etc/timezone +COPY build/libs/*.jar app.jar +CMD ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..5f9217a --- /dev/null +++ b/build.gradle @@ -0,0 +1,77 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.3.10' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.webeye' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +configurations { + compileOnly { + extendsFrom annotationProcessor + } +} + +repositories { + mavenCentral() + maven { + url 'https://repo.spring.io/snapshot' + name = 'Spring Snapshots' + mavenContent { + releasesOnly() + } + } +} + +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:2023.0.2" + mavenBom "org.springframework.ai:spring-ai-bom:1.0.0-M6" + } +} + +dependencies { + runtimeOnly 'com.mysql:mysql-connector-j' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-web' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' + + // spring ai + implementation 'org.springframework.ai:spring-ai-openai-spring-boot-starter:1.0.0-M6' + + // open feign + implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' +} + +tasks.named('test') { + useJUnitPlatform() +} + +def generated = 'src/main/generated' + +tasks.withType(JavaCompile).configureEach { + options.getGeneratedSourceOutputDirectory().set(file(generated)) +} + +sourceSets { + main.java.srcDirs += [ generated ] +} + +clean { + delete file(generated) +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..9bbc975 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..37f853b --- /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.13-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..faf9300 --- /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=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + 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" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# 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..9d21a21 --- /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=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% 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..0f5036d --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'backend' diff --git a/src/main/java/com/webeye/backend/BackendApplication.java b/src/main/java/com/webeye/backend/BackendApplication.java new file mode 100644 index 0000000..7515743 --- /dev/null +++ b/src/main/java/com/webeye/backend/BackendApplication.java @@ -0,0 +1,15 @@ +package com.webeye.backend; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +@EnableJpaAuditing +@SpringBootApplication +public class BackendApplication { + + public static void main(String[] args) { + SpringApplication.run(BackendApplication.class, args); + } + +} diff --git a/src/main/java/com/webeye/backend/allergy/application/AllergyService.java b/src/main/java/com/webeye/backend/allergy/application/AllergyService.java new file mode 100644 index 0000000..7096e12 --- /dev/null +++ b/src/main/java/com/webeye/backend/allergy/application/AllergyService.java @@ -0,0 +1,39 @@ +package com.webeye.backend.allergy.application; + +import com.webeye.backend.allergy.dto.response.AllergyAiResponse; +import com.webeye.backend.imageanalysis.infrastructure.ImageUrlExtractor; +import com.webeye.backend.product.dto.request.FoodProductAnalysisRequest; +import com.webeye.backend.imageanalysis.infrastructure.OpenAiClient; +import com.webeye.backend.product.domain.Product; +import com.webeye.backend.product.domain.ProductAllergy; +import com.webeye.backend.product.persistent.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + + +@Service +@RequiredArgsConstructor +public class AllergyService { + private final OpenAiClient openAiClient; + private final ProductRepository productRepository; + + public AllergyAiResponse analyzeAllergy(FoodProductAnalysisRequest request) { + return openAiClient.explainAllergy(ImageUrlExtractor.extractImageUrlFromHtml(request.html())); + } + + @Transactional + public void saveProductAllergy(Product product, FoodProductAnalysisRequest request) { + AllergyAiResponse response = analyzeAllergy(request); + + response.getAllergyTypes().forEach(allergyType -> + product.addAllergy( + ProductAllergy.builder() + .product(product) + .allergy(allergyType) + .build() + ) + ); + productRepository.save(product); + } +} diff --git a/src/main/java/com/webeye/backend/allergy/dto/response/AllergyAiResponse.java b/src/main/java/com/webeye/backend/allergy/dto/response/AllergyAiResponse.java new file mode 100644 index 0000000..166dd83 --- /dev/null +++ b/src/main/java/com/webeye/backend/allergy/dto/response/AllergyAiResponse.java @@ -0,0 +1,94 @@ +package com.webeye.backend.allergy.dto.response; + +import com.webeye.backend.allergy.type.AllergyType; +import lombok.Builder; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.ArrayList; +import java.util.List; + +@Schema(description = "제품 알레르기 유발 성분 응답") +@Builder +public record AllergyAiResponse( + @Schema(description = "계란 함유 여부") + boolean egg, + + @Schema(description = "우유 함유 여부") + boolean milk, + + @Schema(description = "메밀 함유 여부") + boolean buckwheat, + + @Schema(description = "땅콩 함유 여부") + boolean peanut, + + @Schema(description = "대두 함유 여부") + boolean soybean, + + @Schema(description = "밀 함유 여부") + boolean wheat, + + @Schema(description = "잣 함유 여부") + boolean pineNut, + + @Schema(description = "호두 함유 여부") + boolean walnut, + + @Schema(description = "게 함유 여부") + boolean crab, + + @Schema(description = "새우 함유 여부") + boolean shrimp, + + @Schema(description = "오징어 함유 여부") + boolean squid, + + @Schema(description = "고등어 함유 여부") + boolean mackerel, + + @Schema(description = "조개 함유 여부") + boolean shellfish, + + @Schema(description = "복숭아 함유 여부") + boolean peach, + + @Schema(description = "토마토 함유 여부") + boolean tomato, + + @Schema(description = "닭고기 함유 여부") + boolean chicken, + + @Schema(description = "돼지고기 함유 여부") + boolean pork, + + @Schema(description = "쇠고기 함유 여부") + boolean beef, + + @Schema(description = "아황산류 함유 여부") + boolean sulfite +) { + public List getAllergyTypes() { + List result = new ArrayList<>(); + if (egg) result.add(AllergyType.EGG); + if (milk) result.add(AllergyType.MILK); + if (buckwheat) result.add(AllergyType.BUCKWHEAT); + if (peanut) result.add(AllergyType.PEANUT); + if (soybean) result.add(AllergyType.SOYBEAN); + if (wheat) result.add(AllergyType.WHEAT); + if (pineNut) result.add(AllergyType.PINE_NUT); + if (walnut) result.add(AllergyType.WALNUT); + if (crab) result.add(AllergyType.CRAB); + if (shrimp) result.add(AllergyType.SHRIMP); + if (squid) result.add(AllergyType.SQUID); + if (mackerel) result.add(AllergyType.MACKEREL); + if (shellfish) result.add(AllergyType.SHELLFISH); + if (peach) result.add(AllergyType.PEACH); + if (tomato) result.add(AllergyType.TOMATO); + if (chicken) result.add(AllergyType.CHICKEN); + if (pork) result.add(AllergyType.PORK); + if (beef) result.add(AllergyType.BEEF); + if (sulfite) result.add(AllergyType.SULFITE); + return result; + } +} \ No newline at end of file diff --git a/src/main/java/com/webeye/backend/allergy/type/AllergyType.java b/src/main/java/com/webeye/backend/allergy/type/AllergyType.java new file mode 100644 index 0000000..cad73c7 --- /dev/null +++ b/src/main/java/com/webeye/backend/allergy/type/AllergyType.java @@ -0,0 +1,23 @@ +package com.webeye.backend.allergy.type; + +public enum AllergyType { + EGG, // 계란 + MILK, // 우유 + BUCKWHEAT, // 메밀 + PEANUT, // 땅콩 + SOYBEAN, // 대두 + WHEAT, // 밀 + PINE_NUT, // 잣 + WALNUT, // 호두 + CRAB, // 게 + SHRIMP, // 새우 + SQUID, // 오징어 + MACKEREL, // 고등어 + SHELLFISH, // 조개 + PEACH, // 복숭아 + TOMATO, // 토마토 + CHICKEN, // 닭고기 + PORK, // 돼지고기 + BEEF, // 쇠고기 + SULFITE // 아황산류 +} diff --git a/src/main/java/com/webeye/backend/cosmetic/application/CosmeticService.java b/src/main/java/com/webeye/backend/cosmetic/application/CosmeticService.java new file mode 100644 index 0000000..6c31f28 --- /dev/null +++ b/src/main/java/com/webeye/backend/cosmetic/application/CosmeticService.java @@ -0,0 +1,95 @@ +package com.webeye.backend.cosmetic.application; + +import com.webeye.backend.cosmetic.domain.CosmeticIngredient; +import com.webeye.backend.cosmetic.domain.Ingredient; +import com.webeye.backend.cosmetic.domain.type.IngredientType; +import com.webeye.backend.cosmetic.dto.response.CosmeticResponse; +import com.webeye.backend.cosmetic.infrastructure.mapper.CosmeticIngredientMapper; +import com.webeye.backend.cosmetic.infrastructure.persistence.CosmeticIngredientRepository; +import com.webeye.backend.cosmetic.infrastructure.persistence.IngredientRepository; +import com.webeye.backend.global.error.BusinessException; +import com.webeye.backend.global.error.ErrorCode; +import com.webeye.backend.imageanalysis.infrastructure.ImageUrlExtractor; +import com.webeye.backend.imageanalysis.infrastructure.OpenAiClient; +import com.webeye.backend.product.domain.Product; +import com.webeye.backend.product.domain.type.ProductType; +import com.webeye.backend.product.dto.request.ProductAnalysisRequest; +import com.webeye.backend.product.persistent.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class CosmeticService { + + private final OpenAiClient openAiClient; + private final ProductRepository productRepository; + private final IngredientRepository ingredientRepository; + private final CosmeticIngredientRepository cosmeticIngredientRepository; + + @Transactional + public CosmeticResponse analyzeCosmetic(ProductAnalysisRequest request) { + Product product = findOrCreateProduct(request.productId()); + + // DB에 있을 경우, DB에서 조회 후 호출 + if (product.getCosmeticIngredients() != null && !product.getCosmeticIngredients().isEmpty()) { + return CosmeticIngredientMapper.toResponse(product.getCosmeticIngredients()); + } + + CosmeticResponse response = openAiClient.explainCosmetic( + ImageUrlExtractor.extractImageUrlFromHtml(request.html()) + ); + + Map resultMap = convertToEnumMap(response); + + resultMap.entrySet().stream() + .filter(Map.Entry::getValue) + .forEach(entry -> { + Ingredient ingredient = ingredientRepository.findByIngredientType(entry.getKey()) + .orElseThrow(() -> new BusinessException(ErrorCode.COSMETIC_INGREDIENT_NOT_FOUND)); + + CosmeticIngredient cosmeticIngredient = CosmeticIngredientMapper.toEntity(product, ingredient); + cosmeticIngredientRepository.save(cosmeticIngredient); + }); + + return response; + } + + @Transactional + public Product findOrCreateProduct(String productId) { + return productRepository.findByIdWithCosmeticIngredients(productId) + .orElseGet(() -> productRepository.save( + Product.builder() + .id(productId) + .productType(ProductType.COSMETIC) + .build())); + } + + private Map convertToEnumMap(CosmeticResponse response) { + return Map.ofEntries( + Map.entry(IngredientType.AVOBENZONE, response.avobenzone()), + Map.entry(IngredientType.ISOPROPYL_ALCOHOL, response.isopropylAlcohol()), + Map.entry(IngredientType.SODIUM_LAURYL_SULFATE, response.sodiumLaurylSulfate()), + Map.entry(IngredientType.TRIETHANOLAMINE, response.triethanolamine()), + Map.entry(IngredientType.POLYETHYLENE_GLYCOL, response.polyethyleneGlycol()), + Map.entry(IngredientType.SYNTHETIC_COLORANT, response.syntheticColorant()), + Map.entry(IngredientType.ISOPROPYL_METHYLPHENOL, response.isopropylMethylphenol()), + Map.entry(IngredientType.SORBIC_ACID, response.sorbicAcid()), + Map.entry(IngredientType.HORMONE, response.hormone()), + Map.entry(IngredientType.DIBUTYL_HYDROXYTOLUENE, response.dibutylHydroxyToluene()), + Map.entry(IngredientType.PARABENS, response.parabens()), + Map.entry(IngredientType.TRICLOSAN, response.triclosan()), + Map.entry(IngredientType.BUTYLATED_HYDROXYANISOLE, response.butylatedHydroxyanisole()), + Map.entry(IngredientType.OXYBENZONE, response.oxybenzone()), + Map.entry(IngredientType.IMIDAZOLIDINYL_UREA, response.imidazolidinylUrea()), + Map.entry(IngredientType.MINERAL_OIL, response.mineralOil()), + Map.entry(IngredientType.THYMOL, response.thymol()), + Map.entry(IngredientType.TRIISOPROPANOLAMINE, response.triisopropanolamine()), + Map.entry(IngredientType.SYNTHETIC_FRAGRANCE, response.syntheticFragrance()), + Map.entry(IngredientType.PHENOXYETHANOL, response.phenoxyethanol()) + ); + } +} diff --git a/src/main/java/com/webeye/backend/cosmetic/domain/CosmeticIngredient.java b/src/main/java/com/webeye/backend/cosmetic/domain/CosmeticIngredient.java new file mode 100644 index 0000000..b3598b5 --- /dev/null +++ b/src/main/java/com/webeye/backend/cosmetic/domain/CosmeticIngredient.java @@ -0,0 +1,37 @@ +package com.webeye.backend.cosmetic.domain; + +import com.webeye.backend.global.domain.BaseEntity; +import com.webeye.backend.product.domain.Product; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CosmeticIngredient extends BaseEntity { + + @Id + @Column(name = "cosmetic_ingredient_id", nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id") + private Product product; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ingredient_id") + private Ingredient ingredient; + + @Builder + public CosmeticIngredient( + Product product, + Ingredient ingredient + ) { + this.product = product; + this.ingredient = ingredient; + } +} diff --git a/src/main/java/com/webeye/backend/cosmetic/domain/Ingredient.java b/src/main/java/com/webeye/backend/cosmetic/domain/Ingredient.java new file mode 100644 index 0000000..de7ced0 --- /dev/null +++ b/src/main/java/com/webeye/backend/cosmetic/domain/Ingredient.java @@ -0,0 +1,35 @@ +package com.webeye.backend.cosmetic.domain; + +import com.webeye.backend.cosmetic.domain.type.IngredientType; +import com.webeye.backend.global.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Ingredient extends BaseEntity { + + @Id + @Column(name = "ingredient_id", nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, unique = true) + private IngredientType ingredientType; + + @OneToMany(mappedBy = "ingredient", cascade = CascadeType.ALL, orphanRemoval = true) + private List cosmeticIngredients = new ArrayList<>(); + + @Builder + public Ingredient(IngredientType ingredientType) { + this.ingredientType = ingredientType; + } +} diff --git a/src/main/java/com/webeye/backend/cosmetic/domain/type/IngredientType.java b/src/main/java/com/webeye/backend/cosmetic/domain/type/IngredientType.java new file mode 100644 index 0000000..7d34441 --- /dev/null +++ b/src/main/java/com/webeye/backend/cosmetic/domain/type/IngredientType.java @@ -0,0 +1,33 @@ +package com.webeye.backend.cosmetic.domain.type; + +import lombok.Getter; + +@Getter +public enum IngredientType { + AVOBENZONE("아보벤존"), + ISOPROPYL_ALCOHOL("이소프로필 알코올"), + SODIUM_LAURYL_SULFATE("소듐 라우릴/라우레스 설페이트 (SLS, SLES)"), + TRIETHANOLAMINE("트리에탄올아민"), + POLYETHYLENE_GLYCOL("폴리에틸렌 글라이콜 (PEGs)"), + SYNTHETIC_COLORANT("합성 착색료"), + ISOPROPYL_METHYLPHENOL("이소프로필 메틸페놀"), + SORBIC_ACID("소르빅 애씨드"), + HORMONE("호르몬류"), + DIBUTYL_HYDROXYTOLUENE("디부틸 하이드록시 톨루엔 (BHT)"), + PARABENS("파라벤류 (Methyl-, Ethyl-, Propylparaben 등)"), + TRICLOSAN("트리클로산"), + BUTYLATED_HYDROXYANISOLE("부틸 하이드록시아니솔 (BHA)"), + OXYBENZONE("옥시벤존"), + IMIDAZOLIDINYL_UREA("이미다졸리디닐 우레아, 디아졸리디닐 우레아, DMDM 하이단토인 등"), + MINERAL_OIL("미네랄 오일, 파라핀오일"), + THYMOL("티몰"), + TRIISOPROPANOLAMINE("트라이아이소프로판올아민"), + SYNTHETIC_FRAGRANCE("인공 향료 (Synthetic Fragrance, Parfum)"), + PHENOXYETHANOL("페녹시에탄올"); + + private final String description; + + IngredientType(String description) { + this.description = description; + } +} diff --git a/src/main/java/com/webeye/backend/cosmetic/dto/response/CosmeticResponse.java b/src/main/java/com/webeye/backend/cosmetic/dto/response/CosmeticResponse.java new file mode 100644 index 0000000..80bd107 --- /dev/null +++ b/src/main/java/com/webeye/backend/cosmetic/dto/response/CosmeticResponse.java @@ -0,0 +1,69 @@ +package com.webeye.backend.cosmetic.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Schema(description = "화장품 주의 성분") +@Builder +public record CosmeticResponse( + @Schema(description = "아보벤존 (Avobenzone)") + boolean avobenzone, + + @Schema(description = "이소프로필 알코올 (Isopropyl Alcohol)") + boolean isopropylAlcohol, + + @Schema(description = "소듐 라우릴/라우레스 설페이트 (SLS, SLES)") + boolean sodiumLaurylSulfate, + + @Schema(description = "트리에탄올아민 (Triethanolamine, TEA)") + boolean triethanolamine, + + @Schema(description = "폴리에틸렌 글라이콜 (Polyethylene Glycol, PEGs)") + boolean polyethyleneGlycol, + + @Schema(description = "합성 착색료") + boolean syntheticColorant, + + @Schema(description = "이소프로필 메틸페놀 (Isopropyl Methylphenol, IPMP)") + boolean isopropylMethylphenol, + + @Schema(description = "소르빅 애씨드 (Sorbic Acid)") + boolean sorbicAcid, + + @Schema(description = "호르몬류") + boolean hormone, + + @Schema(description = "디부틸 하이드록시 톨루엔 (Dibutyl Hydroxy Toluene, BHT)") + boolean dibutylHydroxyToluene, + + @Schema(description = "파라벤류 (Methyl-, Ethyl-, Propylparaben 등)") + boolean parabens, + + @Schema(description = "트리클로산 (Triclosan)") + boolean triclosan, + + @Schema(description = "부틸 하이드록시아니솔 (Butylated Hydroxyanisole, BHA)") + boolean butylatedHydroxyanisole, + + @Schema(description = "옥시벤존 (Oxybenzone, Benzophenone-3)") + boolean oxybenzone, + + @Schema(description = "포름알데히드 유도체 (Imidazolidinyl Urea, Diazolidinyl Urea, DMDM Hydantoin 등)") + boolean imidazolidinylUrea, + + @Schema(description = "미네랄 오일 (Mineral Oil, Liquid Paraffin 등)") + boolean mineralOil, + + @Schema(description = "티몰 (Thymol)") + boolean thymol, + + @Schema(description = "트라이아이소프로판올아민 (Triisopropanolamine)") + boolean triisopropanolamine, + + @Schema(description = "인공 향료 (Synthetic Fragrance, Parfum)") + boolean syntheticFragrance, + + @Schema(description = "페녹시에탄올 (Phenoxyethanol)") + boolean phenoxyethanol +) { +} diff --git a/src/main/java/com/webeye/backend/cosmetic/infrastructure/mapper/CosmeticIngredientMapper.java b/src/main/java/com/webeye/backend/cosmetic/infrastructure/mapper/CosmeticIngredientMapper.java new file mode 100644 index 0000000..f3a7048 --- /dev/null +++ b/src/main/java/com/webeye/backend/cosmetic/infrastructure/mapper/CosmeticIngredientMapper.java @@ -0,0 +1,50 @@ +package com.webeye.backend.cosmetic.infrastructure.mapper; + +import com.webeye.backend.cosmetic.domain.CosmeticIngredient; +import com.webeye.backend.cosmetic.domain.Ingredient; +import com.webeye.backend.cosmetic.domain.type.IngredientType; +import com.webeye.backend.cosmetic.dto.response.CosmeticResponse; +import com.webeye.backend.product.domain.Product; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +public class CosmeticIngredientMapper { + + public static CosmeticIngredient toEntity(Product product, Ingredient ingredient) { + return CosmeticIngredient.builder() + .product(product) + .ingredient(ingredient) + .build(); + } + + public static CosmeticResponse toResponse(List ingredients) { + Set present = ingredients.stream() + .map(ci -> ci.getIngredient().getIngredientType()) + .collect(Collectors.toSet()); + + return CosmeticResponse.builder() + .avobenzone(present.contains(IngredientType.AVOBENZONE)) + .isopropylAlcohol(present.contains(IngredientType.ISOPROPYL_ALCOHOL)) + .sodiumLaurylSulfate(present.contains(IngredientType.SODIUM_LAURYL_SULFATE)) + .triethanolamine(present.contains(IngredientType.TRIETHANOLAMINE)) + .polyethyleneGlycol(present.contains(IngredientType.POLYETHYLENE_GLYCOL)) + .syntheticColorant(present.contains(IngredientType.SYNTHETIC_COLORANT)) + .isopropylMethylphenol(present.contains(IngredientType.ISOPROPYL_METHYLPHENOL)) + .sorbicAcid(present.contains(IngredientType.SORBIC_ACID)) + .hormone(present.contains(IngredientType.HORMONE)) + .dibutylHydroxyToluene(present.contains(IngredientType.DIBUTYL_HYDROXYTOLUENE)) + .parabens(present.contains(IngredientType.PARABENS)) + .triclosan(present.contains(IngredientType.TRICLOSAN)) + .butylatedHydroxyanisole(present.contains(IngredientType.BUTYLATED_HYDROXYANISOLE)) + .oxybenzone(present.contains(IngredientType.OXYBENZONE)) + .imidazolidinylUrea(present.contains(IngredientType.IMIDAZOLIDINYL_UREA)) + .mineralOil(present.contains(IngredientType.MINERAL_OIL)) + .thymol(present.contains(IngredientType.THYMOL)) + .triisopropanolamine(present.contains(IngredientType.TRIISOPROPANOLAMINE)) + .syntheticFragrance(present.contains(IngredientType.SYNTHETIC_FRAGRANCE)) + .phenoxyethanol(present.contains(IngredientType.PHENOXYETHANOL)) + .build(); + } +} diff --git a/src/main/java/com/webeye/backend/cosmetic/infrastructure/persistence/CosmeticIngredientRepository.java b/src/main/java/com/webeye/backend/cosmetic/infrastructure/persistence/CosmeticIngredientRepository.java new file mode 100644 index 0000000..1217443 --- /dev/null +++ b/src/main/java/com/webeye/backend/cosmetic/infrastructure/persistence/CosmeticIngredientRepository.java @@ -0,0 +1,7 @@ +package com.webeye.backend.cosmetic.infrastructure.persistence; + +import com.webeye.backend.cosmetic.domain.CosmeticIngredient; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CosmeticIngredientRepository extends JpaRepository { +} diff --git a/src/main/java/com/webeye/backend/cosmetic/infrastructure/persistence/IngredientRepository.java b/src/main/java/com/webeye/backend/cosmetic/infrastructure/persistence/IngredientRepository.java new file mode 100644 index 0000000..5fa0fdc --- /dev/null +++ b/src/main/java/com/webeye/backend/cosmetic/infrastructure/persistence/IngredientRepository.java @@ -0,0 +1,11 @@ +package com.webeye.backend.cosmetic.infrastructure.persistence; + +import com.webeye.backend.cosmetic.domain.Ingredient; +import com.webeye.backend.cosmetic.domain.type.IngredientType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface IngredientRepository extends JpaRepository { + Optional findByIngredientType(IngredientType ingredientType); +} diff --git a/src/main/java/com/webeye/backend/cosmetic/infrastructure/persistence/init/IngredientInit.java b/src/main/java/com/webeye/backend/cosmetic/infrastructure/persistence/init/IngredientInit.java new file mode 100644 index 0000000..5a29198 --- /dev/null +++ b/src/main/java/com/webeye/backend/cosmetic/infrastructure/persistence/init/IngredientInit.java @@ -0,0 +1,33 @@ +package com.webeye.backend.cosmetic.infrastructure.persistence.init; + +import com.webeye.backend.cosmetic.domain.Ingredient; +import com.webeye.backend.cosmetic.domain.type.IngredientType; +import com.webeye.backend.cosmetic.infrastructure.persistence.IngredientRepository; +import com.webeye.backend.global.util.DummyDataInit; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.annotation.Order; + +@Slf4j +@RequiredArgsConstructor +@Order(1) +@DummyDataInit +public class IngredientInit implements ApplicationRunner { + + private final IngredientRepository ingredientRepository; + + @Override + public void run(ApplicationArguments args) { + for (IngredientType ingredientType : IngredientType.values()) { + if (ingredientRepository.findByIngredientType(ingredientType).isEmpty()) { + Ingredient ingredient = Ingredient.builder() + .ingredientType(ingredientType) + .build(); + + ingredientRepository.save(ingredient); + } + } + } +} diff --git a/src/main/java/com/webeye/backend/cosmetic/presentation/CosmeticController.java b/src/main/java/com/webeye/backend/cosmetic/presentation/CosmeticController.java new file mode 100644 index 0000000..4772e29 --- /dev/null +++ b/src/main/java/com/webeye/backend/cosmetic/presentation/CosmeticController.java @@ -0,0 +1,32 @@ +package com.webeye.backend.cosmetic.presentation; + +import com.webeye.backend.cosmetic.application.CosmeticService; +import com.webeye.backend.cosmetic.dto.response.CosmeticResponse; +import com.webeye.backend.cosmetic.presentation.swagger.CosmeticSwagger; +import com.webeye.backend.global.dto.response.SuccessResponse; +import com.webeye.backend.product.dto.request.ProductAnalysisRequest; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import static com.webeye.backend.global.dto.response.type.SuccessCode.COSMETIC_ANALYSIS_SUCCESS; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/cosmetic") +public class CosmeticController implements CosmeticSwagger { + + private final CosmeticService cosmeticService; + + @Override + @ResponseStatus(HttpStatus.OK) + @PostMapping + public SuccessResponse analyzeCosmetic(@Valid @RequestBody ProductAnalysisRequest request) { + return SuccessResponse.of(COSMETIC_ANALYSIS_SUCCESS, cosmeticService.analyzeCosmetic(request)); + } +} diff --git a/src/main/java/com/webeye/backend/cosmetic/presentation/swagger/CosmeticSwagger.java b/src/main/java/com/webeye/backend/cosmetic/presentation/swagger/CosmeticSwagger.java new file mode 100644 index 0000000..d6f3dcf --- /dev/null +++ b/src/main/java/com/webeye/backend/cosmetic/presentation/swagger/CosmeticSwagger.java @@ -0,0 +1,27 @@ +package com.webeye.backend.cosmetic.presentation.swagger; + +import com.webeye.backend.cosmetic.dto.response.CosmeticResponse; +import com.webeye.backend.global.dto.response.SuccessResponse; +import com.webeye.backend.product.dto.request.ProductAnalysisRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "[화장품 주의 성분]", description = "화장품 주의 성분 관련 API") +public interface CosmeticSwagger { + @Operation( + summary = "화장품 주의 원료 성분 추출", + description = "해당 화장품에 대한 주의 원료 성분을 추출합니다." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "화장품 주의 성분이 성공적으로 추출되었습니다." + ) + }) + SuccessResponse analyzeCosmetic( + @RequestBody ProductAnalysisRequest request + ); +} diff --git a/src/main/java/com/webeye/backend/global/config/OpenAiConfig.java b/src/main/java/com/webeye/backend/global/config/OpenAiConfig.java new file mode 100644 index 0000000..88c4030 --- /dev/null +++ b/src/main/java/com/webeye/backend/global/config/OpenAiConfig.java @@ -0,0 +1,13 @@ +package com.webeye.backend.global.config; + +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class OpenAiConfig { + @Bean + ChatClient chatClient(ChatClient.Builder builder) { + return builder.build(); + } +} diff --git a/src/main/java/com/webeye/backend/global/config/OpenFeignConfig.java b/src/main/java/com/webeye/backend/global/config/OpenFeignConfig.java new file mode 100644 index 0000000..baa4166 --- /dev/null +++ b/src/main/java/com/webeye/backend/global/config/OpenFeignConfig.java @@ -0,0 +1,19 @@ +package com.webeye.backend.global.config; + +import feign.Logger; +import org.springframework.boot.autoconfigure.ImportAutoConfiguration; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.cloud.openfeign.FeignAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableFeignClients("com.webeye.backend") +@ImportAutoConfiguration(FeignAutoConfiguration.class) +public class OpenFeignConfig { + + @Bean + Logger.Level feignLoggerLevel() { + return Logger.Level.FULL; + } +} diff --git a/src/main/java/com/webeye/backend/global/config/SwaggerConfig.java b/src/main/java/com/webeye/backend/global/config/SwaggerConfig.java new file mode 100644 index 0000000..fcd5b04 --- /dev/null +++ b/src/main/java/com/webeye/backend/global/config/SwaggerConfig.java @@ -0,0 +1,16 @@ +package com.webeye.backend.global.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.servers.Server; + +@OpenAPIDefinition( + info = @Info(title = "VOIM API 명세서", + description = "VOIM API 명세서", + version = "v1" + ), + servers = @Server(url = "/api", description = "Default Server URL") +) + +public class SwaggerConfig { +} diff --git a/src/main/java/com/webeye/backend/global/config/WebConfig.java b/src/main/java/com/webeye/backend/global/config/WebConfig.java new file mode 100644 index 0000000..81808f1 --- /dev/null +++ b/src/main/java/com/webeye/backend/global/config/WebConfig.java @@ -0,0 +1,33 @@ +package com.webeye.backend.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") + .allowedOriginPatterns( + "https://voim.store", + "http://localhost:3000", + "chrome-extension://iofbhhcbidmfcmpjndglaignlfdojpcm", + "chrome-extension://jeppkpjgeheckphiogogbffdenhlkclh", + "chrome-extension://ecbaebehchfclbcglpabiclbgkjoihmf", + "chrome-extension://lhgneonbbjkppefiifpanchnbaigecil", + "chrome-extension://mphnlcljehhgppcoamgpnnaamidpjkch", + "chrome-extension://licieibgjjilbdjfheljdmllhbopbajo", + "chrome-extension://jbnhfmonamkklnglmdhklbfkojofhppk", + "chrome-extension://ehgaglekgllijnoglmdfeingpecfjbmb", + "chrome-extension://libhakfegdlojphbiaaejoopedaodbgj", + "chrome-extension://lfcaogbnmpkdghiabipdbhbedhinbfdk", + "chrome-extension://hemleepcpkkmkapaliflaohhnnapfdlh", + "chrome-extension://pneljnbmcceppnnphbeljojgmkooblpn" + ) + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders("*") + .allowCredentials(true); + } +} diff --git a/src/main/java/com/webeye/backend/global/domain/BaseEntity.java b/src/main/java/com/webeye/backend/global/domain/BaseEntity.java new file mode 100644 index 0000000..5063f71 --- /dev/null +++ b/src/main/java/com/webeye/backend/global/domain/BaseEntity.java @@ -0,0 +1,24 @@ +package com.webeye.backend.global.domain; + +import jakarta.persistence.Column; +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; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public class BaseEntity { + + @CreatedDate + @Column(updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/webeye/backend/global/dto/response/SuccessResponse.java b/src/main/java/com/webeye/backend/global/dto/response/SuccessResponse.java new file mode 100644 index 0000000..a6c5abf --- /dev/null +++ b/src/main/java/com/webeye/backend/global/dto/response/SuccessResponse.java @@ -0,0 +1,20 @@ +package com.webeye.backend.global.dto.response; + +import com.webeye.backend.global.dto.response.type.SuccessCode; + +public record SuccessResponse( + int status, + String message, + T data +) { + + private static final String NOTHING = ""; + + public static SuccessResponse of(SuccessCode code) { + return new SuccessResponse<>(code.getStatus(), code.getMessage(), NOTHING); + } + + public static SuccessResponse of(SuccessCode code, T data) { + return new SuccessResponse<>(code.getStatus(), code.getMessage(), data); + } +} \ No newline at end of file diff --git a/src/main/java/com/webeye/backend/global/dto/response/type/SuccessCode.java b/src/main/java/com/webeye/backend/global/dto/response/type/SuccessCode.java new file mode 100644 index 0000000..086c407 --- /dev/null +++ b/src/main/java/com/webeye/backend/global/dto/response/type/SuccessCode.java @@ -0,0 +1,36 @@ +package com.webeye.backend.global.dto.response.type; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SuccessCode { + + // health + HEALTH_CHECK_SUCCESS(200, "Health Check Success"), + + // product + FOOD_PRODUCT_ANALYSIS_SUCCESS(200, "Product Analysis Success"), + + // product detail + PRODUCT_DETAIL_EXPLANATION_ANALYSIS_SUCCESS(200, "Product detail explanation analysis success"), + + // explanation + IMAGE_ANALYSIS_SUCCESS(200, "ImageAnalysis Success"), + + // cosmetic + COSMETIC_ANALYSIS_SUCCESS(200, "Cosmetic analysis success"), + + // review + REVIEW_SUMMARY_SUCCESS(200, "Review summary success"), + + // health food + HEALTH_FOOD_API_SUCCESS(200, "Health Food API success"), + HEALTH_FOOD_ANALYSIS_SUCCESS(200, "Health Food analysis success"), + + ; + + private final int status; + private final String message; +} diff --git a/src/main/java/com/webeye/backend/global/error/BusinessException.java b/src/main/java/com/webeye/backend/global/error/BusinessException.java new file mode 100644 index 0000000..4ac3fb5 --- /dev/null +++ b/src/main/java/com/webeye/backend/global/error/BusinessException.java @@ -0,0 +1,13 @@ +package com.webeye.backend.global.error; + +import lombok.Getter; + +@Getter +public class BusinessException extends RuntimeException { + private final ErrorCode errorCode; + + public BusinessException(ErrorCode errorCode) { + super(errorCode.getErrorMessage()); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/webeye/backend/global/error/ErrorCode.java b/src/main/java/com/webeye/backend/global/error/ErrorCode.java new file mode 100644 index 0000000..4a30a8e --- /dev/null +++ b/src/main/java/com/webeye/backend/global/error/ErrorCode.java @@ -0,0 +1,35 @@ +package com.webeye.backend.global.error; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + // open ai + OPEN_AI_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "OpenAI의 응답을 받지 못했습니다."), + OPEN_AI_REFUSAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "OpenAI에서 형식에 맞지 않는 응답이 반환되었습니다."), + FILE_EXTENSION_NOT_FOUND(HttpStatus.BAD_REQUEST, "URL에서 확장자를 찾을 수 없습니다."), + UNSUPPORTED_IMAGE_TYPE(HttpStatus.BAD_REQUEST, "지원하지 않는 이미지 형식입니다."), + INVALID_IMAGE_URL(HttpStatus.BAD_REQUEST, "잘못된 이미지 URL입니다."), + + // image url extract + IMAGE_URL_NOT_FOUND(HttpStatus.NOT_FOUND, "이미지의 URL이 추출되지 않았습니다."), + + // open api + OPEN_API_RESPONSE_NULL(HttpStatus.INTERNAL_SERVER_ERROR, "Open API 응답에 실패했습니다."), + OPEN_API_DATA_MISSING(HttpStatus.NOT_FOUND, "Open API 데이터가 존재하지 않습니다."), + + // product + PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 제품입니다."), + + // nutrient + NUTRIENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 영양성분입니다."), + + // cosmetic + COSMETIC_INGREDIENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 화장품 성분입니다.") + ; + private final HttpStatus status; + private final String errorMessage; +} diff --git a/src/main/java/com/webeye/backend/global/error/ErrorResponse.java b/src/main/java/com/webeye/backend/global/error/ErrorResponse.java new file mode 100644 index 0000000..3848f55 --- /dev/null +++ b/src/main/java/com/webeye/backend/global/error/ErrorResponse.java @@ -0,0 +1,18 @@ +package com.webeye.backend.global.error; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class ErrorResponse { + private final int status; + private final String errorMessage; + + public static ErrorResponse create(final ErrorCode errorCode) { + return new ErrorResponse( + errorCode.getStatus().value(), + errorCode.getErrorMessage() + ); + } +} diff --git a/src/main/java/com/webeye/backend/global/error/GlobalExceptionHandler.java b/src/main/java/com/webeye/backend/global/error/GlobalExceptionHandler.java new file mode 100644 index 0000000..d8dd434 --- /dev/null +++ b/src/main/java/com/webeye/backend/global/error/GlobalExceptionHandler.java @@ -0,0 +1,32 @@ +package com.webeye.backend.global.error; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BusinessException.class) + protected ResponseEntity handleRuntimeException(BusinessException e) { + final ErrorCode errorCode = e.getErrorCode(); + log.warn("🚨Error Log: {}",e.getMessage()); + + return ResponseEntity + .status(errorCode.getStatus()) + .body(new ErrorResponse(errorCode.getStatus().value(), + errorCode.getErrorMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException(MethodArgumentNotValidException e) { + log.warn(e.getMessage()); + return ResponseEntity + .status(e.getStatusCode()) + .body(new ErrorResponse(HttpStatus.BAD_REQUEST.value(), e.getMessage())); + } +} diff --git a/src/main/java/com/webeye/backend/global/presentation/GlobalController.java b/src/main/java/com/webeye/backend/global/presentation/GlobalController.java new file mode 100644 index 0000000..3cb12b4 --- /dev/null +++ b/src/main/java/com/webeye/backend/global/presentation/GlobalController.java @@ -0,0 +1,22 @@ +package com.webeye.backend.global.presentation; + +import com.webeye.backend.global.dto.response.SuccessResponse; +import com.webeye.backend.global.presentation.swagger.GlobalSwagger; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static com.webeye.backend.global.dto.response.type.SuccessCode.HEALTH_CHECK_SUCCESS; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/global") +public class GlobalController implements GlobalSwagger { + @GetMapping("/health-check") + @Override + public ResponseEntity> healthcheck() { + return ResponseEntity.ok(SuccessResponse.of(HEALTH_CHECK_SUCCESS)); + } +} diff --git a/src/main/java/com/webeye/backend/global/presentation/swagger/GlobalSwagger.java b/src/main/java/com/webeye/backend/global/presentation/swagger/GlobalSwagger.java new file mode 100644 index 0000000..05ca3ab --- /dev/null +++ b/src/main/java/com/webeye/backend/global/presentation/swagger/GlobalSwagger.java @@ -0,0 +1,23 @@ +package com.webeye.backend.global.presentation.swagger; + +import com.webeye.backend.global.dto.response.SuccessResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; + +@Tag(name = "Global", description = "설정 확인을 위한 API") +public interface GlobalSwagger { + @Operation( + summary = "health check", + description = "health check API" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "health check success" + ) + }) + ResponseEntity> healthcheck(); +} \ No newline at end of file diff --git a/src/main/java/com/webeye/backend/global/util/DummyDataInit.java b/src/main/java/com/webeye/backend/global/util/DummyDataInit.java new file mode 100644 index 0000000..e6d611c --- /dev/null +++ b/src/main/java/com/webeye/backend/global/util/DummyDataInit.java @@ -0,0 +1,14 @@ +package com.webeye.backend.global.util; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.lang.annotation.*; + +@Documented +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Transactional +@Component +public @interface DummyDataInit { +} diff --git a/src/main/java/com/webeye/backend/healthfood/application/HealthFoodService.java b/src/main/java/com/webeye/backend/healthfood/application/HealthFoodService.java new file mode 100644 index 0000000..463761d --- /dev/null +++ b/src/main/java/com/webeye/backend/healthfood/application/HealthFoodService.java @@ -0,0 +1,113 @@ +package com.webeye.backend.healthfood.application; + +import com.webeye.backend.healthfood.domain.HealthFood; +import com.webeye.backend.healthfood.dto.HealthFoodAiResponse; +import com.webeye.backend.healthfood.dto.HealthFoodKeywordResponse; +import com.webeye.backend.healthfood.dto.HealthFoodResponse; +import com.webeye.backend.healthfood.infrastructure.client.HealthFoodClient; +import com.webeye.backend.healthfood.infrastructure.mapper.HealthFoodMapper; +import com.webeye.backend.healthfood.infrastructure.mapper.ProductHealthFoodMapper; +import com.webeye.backend.healthfood.infrastructure.persistence.HealthFoodRepository; +import com.webeye.backend.imageanalysis.infrastructure.ImageUrlExtractor; +import com.webeye.backend.imageanalysis.infrastructure.OpenAiClient; +import com.webeye.backend.product.domain.Product; +import com.webeye.backend.product.domain.ProductHealthfood; +import com.webeye.backend.product.domain.type.ProductType; +import com.webeye.backend.product.dto.request.FoodProductAnalysisRequest; +import com.webeye.backend.product.persistent.ProductHealthFoodRepository; +import com.webeye.backend.product.persistent.ProductRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class HealthFoodService { + + @Value("${open-api.health-food.service-id}") + private String serviceId; + + @Value("${open-api.health-food.service-key}") + private String serviceKey; + + private final OpenAiClient openAiClient; + private final HealthFoodClient healthFoodClient; + private final ProductRepository productRepository; + private final HealthFoodRepository healthFoodRepository; + private final ProductHealthFoodRepository productHealthFoodRepository; + + @Transactional + public HealthFoodResponse.I2710 callHealthFoodApi() { + HealthFoodResponse response = healthFoodClient.getHealthFood(serviceKey, serviceId, "json", "1", "478"); + + HealthFoodResponse.I2710 i2710 = response.I2710(); + + List rows = i2710.row(); + + List healthFoods = HealthFoodMapper.toEntityList(rows); + + healthFoodRepository.saveAll(healthFoods); + + return HealthFoodMapper.toResponseList(healthFoods, i2710.totalCount()); + } + + @Transactional + public HealthFoodKeywordResponse analyzeAndSaveHealthFood(FoodProductAnalysisRequest request) { + Product product = findOrCreateProduct(request.productId()); + + // DB에 있을 경우, DB에서 조회 후 호출 + if (product.getHealthFoods() != null && !product.getHealthFoods().isEmpty()) { + return HealthFoodMapper.toResponseFromProduct(product); + } + HealthFoodAiResponse response = analyzeHealthFood(request); + + List dbItemNames = healthFoodRepository.findAllItemNames(); + List itemNames = matchItemNames(response.itemNames(), dbItemNames); + + List healthFoods = healthFoodRepository.findByItemNameIn(itemNames); + + saveProductHealthFood(product, healthFoods); + + return HealthFoodMapper.toResponseFromHealthFoods(healthFoods); + } + + public HealthFoodAiResponse analyzeHealthFood(FoodProductAnalysisRequest request) { + return openAiClient.explainHealthFood(ImageUrlExtractor.extractImageUrlFromHtml(request.html())); + } + + @Transactional + public void saveProductHealthFood(Product product, List healthFoods) { + List productHealthFoods = new ArrayList<>(); + + for (HealthFood healthFood : healthFoods) { + ProductHealthfood productHealthfood = ProductHealthFoodMapper.toEntity(product, healthFood); + + product.addHealthFood(productHealthfood); + productHealthFoods.add(productHealthfood); + } + productHealthFoodRepository.saveAll(productHealthFoods); + } + + @Transactional + public Product findOrCreateProduct(String productId) { + return productRepository.findByIdWithHealthFoods(productId) + .orElseGet(() -> productRepository.save( + Product.builder() + .id(productId) + .productType(ProductType.HEALTH_FOOD) + .build())); + } + + private List matchItemNames(List aiItemNames, List dbItemNames) { + return aiItemNames.stream() + .filter(ai -> dbItemNames.stream().anyMatch(ai::contains)) + .distinct() + .toList(); + } +} diff --git a/src/main/java/com/webeye/backend/healthfood/domain/HealthFood.java b/src/main/java/com/webeye/backend/healthfood/domain/HealthFood.java new file mode 100644 index 0000000..7099ea8 --- /dev/null +++ b/src/main/java/com/webeye/backend/healthfood/domain/HealthFood.java @@ -0,0 +1,41 @@ +package com.webeye.backend.healthfood.domain; + +import com.webeye.backend.global.domain.BaseEntity; +import com.webeye.backend.product.domain.ProductHealthfood; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class HealthFood extends BaseEntity { + + @Id + @Column(name = "health_food_id", nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private String itemName; + + @Column(nullable = false, columnDefinition = "TEXT") + private String functionality; + + @OneToMany(mappedBy = "healthFood", cascade = CascadeType.ALL) + private List healthFoodKeywords = new ArrayList<>(); + + @OneToMany(mappedBy = "healthFood", cascade = CascadeType.ALL) + private List healthfoods = new ArrayList<>(); + + @Builder + public HealthFood(String itemName, String functionality) { + this.itemName = itemName; + this.functionality = functionality; + } +} diff --git a/src/main/java/com/webeye/backend/healthfood/domain/HealthFoodKeyword.java b/src/main/java/com/webeye/backend/healthfood/domain/HealthFoodKeyword.java new file mode 100644 index 0000000..777a217 --- /dev/null +++ b/src/main/java/com/webeye/backend/healthfood/domain/HealthFoodKeyword.java @@ -0,0 +1,33 @@ +package com.webeye.backend.healthfood.domain; + +import com.webeye.backend.global.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class HealthFoodKeyword extends BaseEntity { + + @Id + @Column(name = "health_food_keyword_id", nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "health_food_id") + private HealthFood healthFood; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "keyword_id") + private Keyword keyword; + + @Builder + public HealthFoodKeyword(HealthFood healthFood, Keyword keyword) { + this.healthFood = healthFood; + this.keyword = keyword; + } +} diff --git a/src/main/java/com/webeye/backend/healthfood/domain/Keyword.java b/src/main/java/com/webeye/backend/healthfood/domain/Keyword.java new file mode 100644 index 0000000..313f95b --- /dev/null +++ b/src/main/java/com/webeye/backend/healthfood/domain/Keyword.java @@ -0,0 +1,34 @@ +package com.webeye.backend.healthfood.domain; + +import com.webeye.backend.healthfood.domain.type.HealthFoodType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Keyword { + + @Id + @Column(name = "keyword_id", nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, unique = true) + private HealthFoodType type; + + @OneToMany(mappedBy = "keyword", cascade = CascadeType.ALL) + private List healthFoodKeywords = new ArrayList<>(); + + @Builder + public Keyword(HealthFoodType type) { + this.type = type; + } +} diff --git a/src/main/java/com/webeye/backend/healthfood/domain/type/HealthFoodType.java b/src/main/java/com/webeye/backend/healthfood/domain/type/HealthFoodType.java new file mode 100644 index 0000000..b5f29fa --- /dev/null +++ b/src/main/java/com/webeye/backend/healthfood/domain/type/HealthFoodType.java @@ -0,0 +1,45 @@ +package com.webeye.backend.healthfood.domain.type; + +import lombok.Getter; + +@Getter +public enum HealthFoodType { + IMMUNE("면역기능"), + SKIN("피부건강"), + BLOOD("혈액건강"), + BODY_FAT("체지방 감소"), + BLOOD_SUGAR("혈당조절"), + MEMORY("기억력"), + ANTIOXIDANT("항산화"), + GUT("장건강"), + LIVER("간건강"), + EYE("눈건강"), + JOINT("관절건강"), + SLEEP("수면건강"), + STRESS_FATIGUE("스트레스/피로개선"), + MENOPAUSE("갱년기건강"), + PROSTATE("전립선건강"), + URINARY("요로건강"), + ENERGY("에너지대사"), + BONE("뼈건강"), + MUSCLE("근력/운동수행능력"), + COGNITION("인지기능"), + STOMACH("위건강"), + ORAL("구강건강"), + HAIR("모발건강"), + GROWTH("어린이 성장"), + BLOOD_PRESSURE("혈압"), + URINATION("배뇨건강"), + FOLATE("엽산대사"), + NOSE("코건강"), + MALE_HEALTH("남성건강"), + ELECTROLYTE("전해질 균형"), + DIETARY_FIBER("식이섬유"), + ESSENTIAL_FATTY_ACID("필수지방산"); + + private final String description; + + HealthFoodType(String description) { + this.description = description; + } +} diff --git a/src/main/java/com/webeye/backend/healthfood/dto/HealthFoodAiResponse.java b/src/main/java/com/webeye/backend/healthfood/dto/HealthFoodAiResponse.java new file mode 100644 index 0000000..893ba87 --- /dev/null +++ b/src/main/java/com/webeye/backend/healthfood/dto/HealthFoodAiResponse.java @@ -0,0 +1,13 @@ +package com.webeye.backend.healthfood.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema(description = "건강 기능 식품 원료명 추출") +public record HealthFoodAiResponse( + List itemNames +) { +} diff --git a/src/main/java/com/webeye/backend/healthfood/dto/HealthFoodKeywordResponse.java b/src/main/java/com/webeye/backend/healthfood/dto/HealthFoodKeywordResponse.java new file mode 100644 index 0000000..a510789 --- /dev/null +++ b/src/main/java/com/webeye/backend/healthfood/dto/HealthFoodKeywordResponse.java @@ -0,0 +1,14 @@ +package com.webeye.backend.healthfood.dto; + +import com.webeye.backend.healthfood.domain.type.HealthFoodType; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema(description = "건강 기능 식품 키워드 분석") +public record HealthFoodKeywordResponse( + List types +) { +} diff --git a/src/main/java/com/webeye/backend/healthfood/dto/HealthFoodResponse.java b/src/main/java/com/webeye/backend/healthfood/dto/HealthFoodResponse.java new file mode 100644 index 0000000..36fa3cc --- /dev/null +++ b/src/main/java/com/webeye/backend/healthfood/dto/HealthFoodResponse.java @@ -0,0 +1,20 @@ +package com.webeye.backend.healthfood.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +public record HealthFoodResponse( + I2710 I2710 +) { + public record I2710( + @JsonProperty("total_count") + Integer totalCount, + List row + ){} + + public record Row( + String PRDCT_NM, + String PRIMARY_FNCLTY + ){} +} diff --git a/src/main/java/com/webeye/backend/healthfood/infrastructure/client/HealthFoodClient.java b/src/main/java/com/webeye/backend/healthfood/infrastructure/client/HealthFoodClient.java new file mode 100644 index 0000000..03695bc --- /dev/null +++ b/src/main/java/com/webeye/backend/healthfood/infrastructure/client/HealthFoodClient.java @@ -0,0 +1,24 @@ +package com.webeye.backend.healthfood.infrastructure.client; + +import com.webeye.backend.global.config.OpenFeignConfig; +import com.webeye.backend.healthfood.dto.HealthFoodResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +@FeignClient( + name = "healthFoodClient", + url = "${open-api.health-food.url}", + configuration = OpenFeignConfig.class +) +public interface HealthFoodClient { + + @GetMapping("/{keyId}/{serviceId}/{dataType}/{startIdx}/{endIdx}") + HealthFoodResponse getHealthFood( + @PathVariable("keyId") String keyId, + @PathVariable("serviceId") String serviceId, + @PathVariable("dataType") String dataType, + @PathVariable("startIdx") String startIdx, + @PathVariable("endIdx") String endIdx + ); +} diff --git a/src/main/java/com/webeye/backend/healthfood/infrastructure/mapper/HealthFoodMapper.java b/src/main/java/com/webeye/backend/healthfood/infrastructure/mapper/HealthFoodMapper.java new file mode 100644 index 0000000..eae2b86 --- /dev/null +++ b/src/main/java/com/webeye/backend/healthfood/infrastructure/mapper/HealthFoodMapper.java @@ -0,0 +1,69 @@ +package com.webeye.backend.healthfood.infrastructure.mapper; + +import com.webeye.backend.healthfood.domain.HealthFood; +import com.webeye.backend.healthfood.domain.HealthFoodKeyword; +import com.webeye.backend.healthfood.domain.Keyword; +import com.webeye.backend.healthfood.domain.type.HealthFoodType; +import com.webeye.backend.healthfood.dto.HealthFoodKeywordResponse; +import com.webeye.backend.healthfood.dto.HealthFoodResponse; +import com.webeye.backend.product.domain.Product; +import com.webeye.backend.product.domain.ProductHealthfood; + +import java.util.List; +import java.util.stream.Collectors; + +public class HealthFoodMapper { + + public static HealthFood toEntity(HealthFoodResponse.Row row) { + return HealthFood.builder() + .itemName(row.PRDCT_NM()) + .functionality(row.PRIMARY_FNCLTY()) + .build(); + } + + public static List toEntityList(List rows) { + return rows.stream() + .map(HealthFoodMapper::toEntity) + .collect(Collectors.toList()); + } + + public static HealthFoodResponse.Row toResponse(HealthFood healthFood) { + return new HealthFoodResponse.Row( + healthFood.getItemName(), + healthFood.getFunctionality() + ); + } + + public static HealthFoodResponse.I2710 toResponseList(List healthFoodList, Integer totalCount) { + List i2710List = healthFoodList.stream() + .map(HealthFoodMapper::toResponse) + .collect(Collectors.toList()); + + return new HealthFoodResponse.I2710(totalCount, i2710List); + } + + public static HealthFoodKeywordResponse toResponseFromProduct(Product product) { + List healthFoods = product.getHealthFoods().stream() + .map(ProductHealthfood::getHealthFood) + .toList(); + + return toResponseFromHealthFoods(healthFoods); + } + + public static HealthFoodKeywordResponse toResponseFromHealthFoods(List healthFoods) { + List types = extractTypes(healthFoods); + + return HealthFoodKeywordResponse.builder() + .types(types) + .build(); + } + + private static List extractTypes(List healthFoods) { + return healthFoods.stream() + .flatMap(healthFood -> healthFood.getHealthFoodKeywords().stream()) + .map(HealthFoodKeyword::getKeyword) + .map(Keyword::getType) + .distinct() + .toList(); + } +} diff --git a/src/main/java/com/webeye/backend/healthfood/infrastructure/mapper/ProductHealthFoodMapper.java b/src/main/java/com/webeye/backend/healthfood/infrastructure/mapper/ProductHealthFoodMapper.java new file mode 100644 index 0000000..e22bf58 --- /dev/null +++ b/src/main/java/com/webeye/backend/healthfood/infrastructure/mapper/ProductHealthFoodMapper.java @@ -0,0 +1,15 @@ +package com.webeye.backend.healthfood.infrastructure.mapper; + +import com.webeye.backend.healthfood.domain.HealthFood; +import com.webeye.backend.product.domain.Product; +import com.webeye.backend.product.domain.ProductHealthfood; + +public class ProductHealthFoodMapper { + + public static ProductHealthfood toEntity(Product product, HealthFood healthFood) { + return ProductHealthfood.builder() + .product(product) + .healthFood(healthFood) + .build(); + } +} diff --git a/src/main/java/com/webeye/backend/healthfood/infrastructure/persistence/HealthFoodKeywordRepository.java b/src/main/java/com/webeye/backend/healthfood/infrastructure/persistence/HealthFoodKeywordRepository.java new file mode 100644 index 0000000..48d807d --- /dev/null +++ b/src/main/java/com/webeye/backend/healthfood/infrastructure/persistence/HealthFoodKeywordRepository.java @@ -0,0 +1,7 @@ +package com.webeye.backend.healthfood.infrastructure.persistence; + +import com.webeye.backend.healthfood.domain.HealthFoodKeyword; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface HealthFoodKeywordRepository extends JpaRepository { +} diff --git a/src/main/java/com/webeye/backend/healthfood/infrastructure/persistence/HealthFoodRepository.java b/src/main/java/com/webeye/backend/healthfood/infrastructure/persistence/HealthFoodRepository.java new file mode 100644 index 0000000..dbc34b7 --- /dev/null +++ b/src/main/java/com/webeye/backend/healthfood/infrastructure/persistence/HealthFoodRepository.java @@ -0,0 +1,15 @@ +package com.webeye.backend.healthfood.infrastructure.persistence; + +import com.webeye.backend.healthfood.domain.HealthFood; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; + +public interface HealthFoodRepository extends JpaRepository { + @Query("SELECT h.itemName FROM HealthFood h") + List findAllItemNames(); + Optional findByItemNameContaining(String itemName); + List findByItemNameIn(List itemNames); +} diff --git a/src/main/java/com/webeye/backend/healthfood/infrastructure/persistence/KeywordRepository.java b/src/main/java/com/webeye/backend/healthfood/infrastructure/persistence/KeywordRepository.java new file mode 100644 index 0000000..26cb105 --- /dev/null +++ b/src/main/java/com/webeye/backend/healthfood/infrastructure/persistence/KeywordRepository.java @@ -0,0 +1,11 @@ +package com.webeye.backend.healthfood.infrastructure.persistence; + +import com.webeye.backend.healthfood.domain.Keyword; +import com.webeye.backend.healthfood.domain.type.HealthFoodType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface KeywordRepository extends JpaRepository { + Optional findByType(HealthFoodType type); +} diff --git a/src/main/java/com/webeye/backend/healthfood/infrastructure/persistence/init/HealthFoodInit.java b/src/main/java/com/webeye/backend/healthfood/infrastructure/persistence/init/HealthFoodInit.java new file mode 100644 index 0000000..2c22497 --- /dev/null +++ b/src/main/java/com/webeye/backend/healthfood/infrastructure/persistence/init/HealthFoodInit.java @@ -0,0 +1,33 @@ +package com.webeye.backend.healthfood.infrastructure.persistence.init; + +import com.webeye.backend.global.util.DummyDataInit; +import com.webeye.backend.healthfood.domain.Keyword; +import com.webeye.backend.healthfood.domain.type.HealthFoodType; +import com.webeye.backend.healthfood.infrastructure.persistence.KeywordRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.annotation.Order; + +@Slf4j +@RequiredArgsConstructor +@Order(1) +@DummyDataInit +public class HealthFoodInit implements ApplicationRunner { + + private final KeywordRepository keywordRepository; + + @Override + public void run(ApplicationArguments args) { + for (HealthFoodType healthFoodType : HealthFoodType.values()) { + if (keywordRepository.findByType(healthFoodType).isEmpty()) { + Keyword keyword = Keyword.builder() + .type(healthFoodType) + .build(); + + keywordRepository.save(keyword); + } + } + } +} diff --git a/src/main/java/com/webeye/backend/healthfood/presentation/HealthFoodController.java b/src/main/java/com/webeye/backend/healthfood/presentation/HealthFoodController.java new file mode 100644 index 0000000..60475ee --- /dev/null +++ b/src/main/java/com/webeye/backend/healthfood/presentation/HealthFoodController.java @@ -0,0 +1,36 @@ +package com.webeye.backend.healthfood.presentation; + +import com.webeye.backend.global.dto.response.SuccessResponse; +import com.webeye.backend.healthfood.application.HealthFoodService; +import com.webeye.backend.healthfood.dto.HealthFoodKeywordResponse; +import com.webeye.backend.healthfood.presentation.swagger.HealthFoodSwagger; +import com.webeye.backend.product.dto.request.FoodProductAnalysisRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import static com.webeye.backend.global.dto.response.type.SuccessCode.HEALTH_FOOD_API_SUCCESS; +import static com.webeye.backend.global.dto.response.type.SuccessCode.HEALTH_FOOD_ANALYSIS_SUCCESS; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/health-food") +public class HealthFoodController implements HealthFoodSwagger { + + private final HealthFoodService healthFoodService; + +// @Override +// @ResponseStatus(HttpStatus.OK) +// @GetMapping +// public SuccessResponse callHealthFoodApi() { +// return SuccessResponse.of(HEALTH_FOOD_API_SUCCESS, healthFoodService.callHealthFoodApi()); +// } + + @Override + @ResponseStatus(HttpStatus.OK) + @PostMapping("/keywords") + public SuccessResponse analyzeHealthFood(@Valid @RequestBody FoodProductAnalysisRequest request) { + return SuccessResponse.of(HEALTH_FOOD_ANALYSIS_SUCCESS, healthFoodService.analyzeAndSaveHealthFood(request)); + } +} diff --git a/src/main/java/com/webeye/backend/healthfood/presentation/swagger/HealthFoodSwagger.java b/src/main/java/com/webeye/backend/healthfood/presentation/swagger/HealthFoodSwagger.java new file mode 100644 index 0000000..3d40e10 --- /dev/null +++ b/src/main/java/com/webeye/backend/healthfood/presentation/swagger/HealthFoodSwagger.java @@ -0,0 +1,40 @@ +package com.webeye.backend.healthfood.presentation.swagger; + +import com.webeye.backend.global.dto.response.SuccessResponse; +import com.webeye.backend.healthfood.dto.HealthFoodKeywordResponse; +import com.webeye.backend.product.dto.request.FoodProductAnalysisRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "[건강 기능 식품]", description = "건강 기능 식품 관련 API") +public interface HealthFoodSwagger { +// @Operation( +// summary = "건강 기능 식품 OPEN API 호출", +// description = "식품 안전 나라 건강 기능 식품 품목 분류 정보 OPEN API를 호출합니다." +// ) +// @ApiResponses(value = { +// @ApiResponse( +// responseCode = "200", +// description = "OPEN API가 성공적으로 호출되었습니다." +// ) +// }) +// SuccessResponse callHealthFoodApi(); + + @Operation( + summary = "건강 기능 식품 효능 키워드 분석", + description = "해당 건강 기능 식품의 효능을 분석하고 키워드를 표출합니다." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "건강 기능 식품 효능 키워드 분석이 성공적으로 실행되었습니다." + ) + }) + SuccessResponse analyzeHealthFood( + @Valid @RequestBody FoodProductAnalysisRequest request + ); +} diff --git a/src/main/java/com/webeye/backend/imageanalysis/application/ImageAnalysisService.java b/src/main/java/com/webeye/backend/imageanalysis/application/ImageAnalysisService.java new file mode 100644 index 0000000..ce1ed4a --- /dev/null +++ b/src/main/java/com/webeye/backend/imageanalysis/application/ImageAnalysisService.java @@ -0,0 +1,18 @@ +package com.webeye.backend.imageanalysis.application; + +import com.webeye.backend.imageanalysis.dto.request.ImageAnalysisRequest; +import com.webeye.backend.imageanalysis.dto.response.ImageAnalysisResponse; +import com.webeye.backend.imageanalysis.infrastructure.OpenAiClient; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class ImageAnalysisService { + private final OpenAiClient openAiClient; + + public ImageAnalysisResponse analyzeImage(ImageAnalysisRequest request) { + return openAiClient.explainImage(request); + } + +} diff --git a/src/main/java/com/webeye/backend/imageanalysis/dto/request/ImageAnalysisPrompt.java b/src/main/java/com/webeye/backend/imageanalysis/dto/request/ImageAnalysisPrompt.java new file mode 100644 index 0000000..e17f965 --- /dev/null +++ b/src/main/java/com/webeye/backend/imageanalysis/dto/request/ImageAnalysisPrompt.java @@ -0,0 +1,7 @@ +package com.webeye.backend.imageanalysis.dto.request; + +public record ImageAnalysisPrompt ( + String system, + String user +) { +} diff --git a/src/main/java/com/webeye/backend/imageanalysis/dto/request/ImageAnalysisRequest.java b/src/main/java/com/webeye/backend/imageanalysis/dto/request/ImageAnalysisRequest.java new file mode 100644 index 0000000..f625f08 --- /dev/null +++ b/src/main/java/com/webeye/backend/imageanalysis/dto/request/ImageAnalysisRequest.java @@ -0,0 +1,14 @@ +package com.webeye.backend.imageanalysis.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import org.hibernate.validator.constraints.URL; + +@Schema(description = "이미지 분석") +public record ImageAnalysisRequest( + @Schema(description = "상품 이미지 URL") + @NotEmpty(message = "이미지 URL 목록은 비어있을 수 없습니다.") + @URL + String url +) { +} diff --git a/src/main/java/com/webeye/backend/imageanalysis/dto/response/ImageAnalysisResponse.java b/src/main/java/com/webeye/backend/imageanalysis/dto/response/ImageAnalysisResponse.java new file mode 100644 index 0000000..d86237a --- /dev/null +++ b/src/main/java/com/webeye/backend/imageanalysis/dto/response/ImageAnalysisResponse.java @@ -0,0 +1,11 @@ +package com.webeye.backend.imageanalysis.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Schema(description = "이미지 분석 응답") +@Builder +public record ImageAnalysisResponse( + String analysis +) { +} diff --git a/src/main/java/com/webeye/backend/imageanalysis/infrastructure/ImageMimeType.java b/src/main/java/com/webeye/backend/imageanalysis/infrastructure/ImageMimeType.java new file mode 100644 index 0000000..1326bb4 --- /dev/null +++ b/src/main/java/com/webeye/backend/imageanalysis/infrastructure/ImageMimeType.java @@ -0,0 +1,43 @@ +package com.webeye.backend.imageanalysis.infrastructure; + +import com.webeye.backend.global.error.BusinessException; +import org.springframework.util.MimeType; +import org.springframework.util.MimeTypeUtils; + +import java.util.Arrays; +import java.util.Map; +import java.util.stream.Collectors; + +import static com.webeye.backend.global.error.ErrorCode.UNSUPPORTED_IMAGE_TYPE; + +public enum ImageMimeType { + PNG("png", MimeTypeUtils.IMAGE_PNG), + JPEG("jpeg", MimeTypeUtils.IMAGE_JPEG), + JPG("jpg", MimeTypeUtils.IMAGE_JPEG), + GIF("gif", MimeTypeUtils.IMAGE_GIF), + WEBP("webp", MimeTypeUtils.parseMimeType("image/webp")); + + private final String extension; + private final MimeType mimeType; + + private static final Map EXTENSION_TO_MIMETYPE_MAP; + + static { + EXTENSION_TO_MIMETYPE_MAP = Arrays.stream(values()) + .collect(Collectors.toMap(type -> type.extension, type -> type.mimeType)); + } + + ImageMimeType(String extension, MimeType mimeType) { + this.extension = extension; + this.mimeType = mimeType; + } + + public static MimeType fromExtension(String extension) { + String lowerExtension = extension.toLowerCase(); + MimeType mimeType = EXTENSION_TO_MIMETYPE_MAP.get(lowerExtension); + if (mimeType == null) { + throw new BusinessException(UNSUPPORTED_IMAGE_TYPE); + } + return mimeType; + } +} \ No newline at end of file diff --git a/src/main/java/com/webeye/backend/imageanalysis/infrastructure/ImageUrlExtractor.java b/src/main/java/com/webeye/backend/imageanalysis/infrastructure/ImageUrlExtractor.java new file mode 100644 index 0000000..ca994fc --- /dev/null +++ b/src/main/java/com/webeye/backend/imageanalysis/infrastructure/ImageUrlExtractor.java @@ -0,0 +1,63 @@ +package com.webeye.backend.imageanalysis.infrastructure; + +import com.webeye.backend.global.error.BusinessException; +import com.webeye.backend.global.error.ErrorCode; +import groovy.json.StringEscapeUtils; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Slf4j +public class ImageUrlExtractor { + private static final int MAX_IMAGE_COUNT = 17; + + public static List extractImageUrlFromHtml(String html) { + String unescapedHtml = unescapeHtml(html); + List imageUrls = extractImageUrls(unescapedHtml); + List trimmedUrls = trimToMaxSize(imageUrls, MAX_IMAGE_COUNT); + + log.info("extracted urls: {}", trimmedUrls); + log.info("total number of images: {}", trimmedUrls.size()); + + if (trimmedUrls.isEmpty()) { + throw new BusinessException(ErrorCode.IMAGE_URL_NOT_FOUND); + } + + return trimmedUrls; + } + + private static String unescapeHtml(String html) { + return StringEscapeUtils.unescapeJava(html); + } + + private static List extractImageUrls(String html) { + Pattern pattern = Pattern.compile("]+src=[\"']((https?:)?//[^\"']+)[\"']"); + Matcher matcher = pattern.matcher(html); + + List imageUrls = new ArrayList<>(); + + while (matcher.find()) { + String rawUrl = matcher.group(1); + + if (rawUrl.startsWith("//")) { + rawUrl = "https:" + rawUrl; + } else if (rawUrl.startsWith("http://")) { + rawUrl = rawUrl.replaceFirst("http://", "https://"); + } + + imageUrls.add(rawUrl); + } + + return imageUrls; + } + + private static List trimToMaxSize(List urls, int maxSize) { + if (urls.size() <= maxSize) { + return urls; + } + return new ArrayList<>(urls.subList(urls.size() - maxSize, urls.size())); + } +} \ No newline at end of file diff --git a/src/main/java/com/webeye/backend/imageanalysis/infrastructure/OpenAiClient.java b/src/main/java/com/webeye/backend/imageanalysis/infrastructure/OpenAiClient.java new file mode 100644 index 0000000..3d2eb1c --- /dev/null +++ b/src/main/java/com/webeye/backend/imageanalysis/infrastructure/OpenAiClient.java @@ -0,0 +1,265 @@ +package com.webeye.backend.imageanalysis.infrastructure; + +import com.webeye.backend.allergy.dto.response.AllergyAiResponse; +import com.webeye.backend.cosmetic.dto.response.CosmeticResponse; +import com.webeye.backend.imageanalysis.dto.request.ImageAnalysisRequest; +import com.webeye.backend.imageanalysis.dto.response.ImageAnalysisResponse; +import com.webeye.backend.global.error.BusinessException; +import com.webeye.backend.healthfood.dto.HealthFoodAiResponse; +import com.webeye.backend.imageanalysis.dto.request.ImageAnalysisPrompt; +import com.webeye.backend.productdetail.domain.type.OutlineType; +import com.webeye.backend.product.dto.request.FoodProductAnalysisRequest; +import com.webeye.backend.nutrition.dto.response.NutritionAiResponse; +import com.webeye.backend.productdetail.dto.response.AllDetailExplanationResponse; +import com.webeye.backend.rawmaterial.dto.response.RawMaterialAiResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.client.ChatClient; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.SystemMessage; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.converter.BeanOutputConverter; +import org.springframework.core.io.UrlResource; +import org.springframework.stereotype.Component; +import org.springframework.util.MimeType; + +import java.net.MalformedURLException; +import java.util.List; + +import static com.webeye.backend.global.error.ErrorCode.FILE_EXTENSION_NOT_FOUND; +import static com.webeye.backend.global.error.ErrorCode.INVALID_IMAGE_URL; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OpenAiClient { + private final ChatClient chatClient; + + + public AllDetailExplanationResponse explainProductAllDetail(List urls) { + String system = """ + You are an expert in providing detailed explanations about products based on images. + When a user provides a product description image along with the key outline of that description, you should offer a clear and detailed explanation of that element. + In this explanation, you must provide very detailed information about that element from the image. Answer in Korean. + The output must use a descriptive tone ending in "~입니다." or "~합니다." Only declarative sentences are allowed. Do not use imperative, interrogative, or any other sentence endings. + Please separate each sentence onto a new line using \\n as the line break. + """; + String user = String.format(""" + Please generate a detailed explanation of the provided key. + Provide your answer following the FORMAT I provided. I will specify what content should be included for each key. + In your response, if there are any words that contain important information, please wrap them with the HTML tag. + main: %s + usage: %s + warning: %s + specs: %s + """, OutlineType.MAIN.getPrompt(), OutlineType.USAGE.getPrompt(), OutlineType.WARNING.getPrompt(), OutlineType.SPECS.getPrompt()); + + ImageAnalysisPrompt prompt = new ImageAnalysisPrompt(system, user); + return callWithStructuredOutput(urls, prompt, AllDetailExplanationResponse.class); + } + + public AllergyAiResponse explainAllergy(List urls) { + String system = """ + You are an OCR assistant that extracts and detects allergenic ingredients from Korean product label images. Always treat partial matches inside compound words as valid if they contain the full Korean name of an allergen. + """; + + String user = """ + Step 1: Carefully examine the attached image(s). + + Identify the box that contains the full list of ingredients, labeled with '원재료명' or similar (e.g., '원재료 및 함량'). + Only extract text from this box — ignore all other parts of the image, including allergy warnings or separate notices. + If there is no match or anything similar, skip to Step 4 and set the value of all keys to false. + + Step 2: From the identified box, extract the ingredient list **exactly as written**, without summarizing, translating, or omitting anything — including words like '분말', '가루', or '함유'. + + Step 3: Compare the text to the following list of allergenic ingredients: + 계란(EGG), 우유(MILK), 메밀(BUCKWHEAT), 땅콩(PEANUT), 대두(SOYBEAN), 밀(WHEAT), 잣(PINE_NUT), 호두(WALNUT), 게(CRAB), 새우(SHRIMP), 오징어(SQUID), 고등어(MACKEREL), 조개(SHELLFISH), 복숭아(PEACH), 토마토(TOMATO), 닭고기(CHICKEN), 돼지고기(PORK), 쇠고기(BEEF), 아황산류(SULFITE). + + Return true for an allergen if its full Korean name appears **anywhere inside any word** in the ingredient list — even if it is part of a compound word (e.g., "호두함유", "호두분말", "밀가루"). + + Return false if the full Korean allergen word does not appear in any part of the ingredient text. + + Step 4: Output a list of booleans (true/false), in the same order as the allergen list above. + """; + + ImageAnalysisPrompt prompt = new ImageAnalysisPrompt(system, user); + return callWithStructuredOutput(urls, prompt, AllergyAiResponse.class); + } + + public NutritionAiResponse explainNutrition(List urls) { + String system = """ + You are a nutrition description assistant. + """; + + String user = """ + If the attached images contain 'nutrition information', please provide the amount of each nutrient in the format I sent. + If the nutritional information is not included, set the isNutrientIncluded field to false; if it is included, set it to true. + You are given a nutrition label image. Extract the number of grams that the nutritional values are based on. + This is typically written as "per 100g", "per 1 serving (XXg)", "100 g당" (in Korean), or similar. + Return only the numeric gram value in a field named nutrientReferenceAmount. + """; + + ImageAnalysisPrompt prompt = new ImageAnalysisPrompt(system, user); + return callWithStructuredOutput(urls, prompt, NutritionAiResponse.class); + } + + + public CosmeticResponse explainCosmetic(List urls) { + String system = """ + You are an expert in identifying harmful cosmetic ingredients based on Korean labels. + You always return exact matches based on a predefined Korean-to-English mapping. + """; + + String user = """ + Carefully examine the attached image. Focus only on **Korean ingredient names** that appear under the section labeled **"전성분"**. + + Your task is to check for the exact **presence** of the following Korean ingredient names — but **only within the "전성분" section**. + + These names must appear **exactly and completely**, including spacing and punctuation. + Ignore any matches that appear **outside of the "전성분" section**, or are **partial/similar**. + + Use this mapping: + { + "아보벤존": "avobenzone", + "이소프로필 알코올": "isopropylAlcohol", + "소듐 라우릴/라우레스 설페이트 (SLS, SLES)": "sodiumLaurylSulfate", + "트리에탄올아민": "triethanolamine", + "폴리에틸렌 글라이콜 (PEGs)": "polyethyleneGlycol", + "합성 착색료": "syntheticColorant", + "이소프로필 메틸페놀": "isopropylMethylphenol", + "소르빅 애씨드": "sorbicAcid", + "호르몬류": "hormone", + "디부틸 하이드록시 톨루엔 (BHT)": "dibutylHydroxyToluene", + "파라벤류 (Methyl-, Ethyl-, Propylparaben 등)": "parabens", + "트리클로산": "triclosan", + "부틸 하이드록시아니솔 (BHA)": "butylatedHydroxyanisole", + "옥시벤존": "oxybenzone", + "이미다졸리디닐 우레아, 디아졸리디닐 우레아, DMDM 하이단토인 등": "imidazolidinylUrea", + "미네랄 오일, 파라핀오일": "mineralOil", + "티몰": "thymol", + "트라이아이소프로판올아민": "triisopropanolamine", + "인공 향료 (Synthetic Fragrance, Parfum)": "syntheticFragrance", + "페녹시에탄올": "phenoxyethanol" + } + Return true only if the exact full Korean ingredient name appears continuously and separately; otherwise, return false. + Ignore partial, similar, or incomplete matches. + + + Note: "인공 향료 (Synthetic Fragrance, Parfum)" is considered the same as "향료" or "Fragrance" — treat all of them as matching "syntheticFragrance". + """; + + ImageAnalysisPrompt prompt = new ImageAnalysisPrompt(system, user); + return callWithStructuredOutput(urls, prompt, CosmeticResponse.class); + } + + public HealthFoodAiResponse explainHealthFood(List urls) { + String system = """ + You are a food label OCR expert. Your task is to extract ingredient names from Korean health supplement product images. + You must return only a list of ingredient names, in JSON format. Do not summarize or explain anything. + """; + + String user = """ + Please examine the attached image of a Korean health food product. + + Look for the section that lists ingredients. This section is usually labeled with: + - '원재료명' + - '원재료 및 함량' + - '영양·기능정보' + - or similar titles + + From this section, extract only the ingredient names. For example: + - "비타민C 100mg" → "비타민C" + - "베타카로틴함유" → "베타카로틴" + - "정제수(물)" → "정제수" + + Ignore quantities (mg, %, g), descriptors (함유, 분말), or anything in parentheses + + Return only the names of the ingredients in the following JSON format: + { + "itemNames": ["비타민C", "베타카로틴", "정제수"] + } + + If no ingredients are found, return: + { + "itemNames": [] + } + """; + + ImageAnalysisPrompt prompt = new ImageAnalysisPrompt(system, user); + return callWithStructuredOutput(urls, prompt, HealthFoodAiResponse.class); + } + + public ImageAnalysisResponse explainImage(ImageAnalysisRequest request) { + String system = """ + You are a helpful and concise assistant that specializes in describing images. + Always provide clear, accurate, and human-like descriptions of the image content. + Focus on the most important visual details such as objects, people, actions, scenes, and context. + Avoid speculation unless necessary, and do not include irrelevant information. Answer in Korean. + """; + String user = """ + Please describe the contents of this image in detail. You must respond in Korean. + """; + + ImageAnalysisPrompt prompt = new ImageAnalysisPrompt(system, user); + return callWithStructuredOutput(List.of(request.url()), prompt, ImageAnalysisResponse.class); + } + + private T callWithStructuredOutput(List urls, ImageAnalysisPrompt prompt, Class clazz) { + BeanOutputConverter outputConverter = new BeanOutputConverter<>(clazz); + + String response = chatClient.prompt() + .user(promptUserSpec -> { + try { + promptUserSpec.text(prompt.user() + outputConverter.getFormat()); + for (String imageUrl : urls) { + MimeType extension = ImageMimeType.fromExtension(extractFileExtension(imageUrl)); + promptUserSpec.media(extension, new UrlResource(imageUrl)); + } + } catch (MalformedURLException exception) { + log.error("MalformedURLException: callWithStructuredOutput() 에서 발생"); + throw new BusinessException(INVALID_IMAGE_URL); + } + }) + .system(prompt.system()) + .call() + .content(); + + return outputConverter.convert(response); + } + + private String extractFileExtension(String url) { + String fileName = url.substring(url.lastIndexOf('/') + 1); + int dotIndex = fileName.lastIndexOf('.'); + if (dotIndex == -1) { + throw new BusinessException(FILE_EXTENSION_NOT_FOUND); + } + return fileName.substring(dotIndex + 1); + } + + + public RawMaterialAiResponse explainRawMaterial(FoodProductAnalysisRequest request) { + Message systemMessage = new SystemMessage(""" + 너는 원재료 식품의 이름을 반환하는 어시스턴트이다. + """); + + Message userMessage = new UserMessage(String.format(""" + %s + 다음은 온라인 쇼핑몰의 식품 제목입니다. 불필요한 수식어나 단위를 제거하고, 핵심 식품명만 추출하세요. 부위명(예: 살, 조각, 포, 덩어리 등)은 제거하고, 일반 식재료명을 사용하세요. 가능한 한 짧고 일반적인 명사 형태로 출력하십시오. + (참고로 "고구마순"은 "고구마_줄기" 이다.) + 예시: + 1. 달콤한 꿀고구마, 1박스, 10kg 못난이 (꿀&호박 랜덤발송) → 꿀고구마 + 2. 건나물 말린 토란줄기 토란대 미얀마산 1kg, 1개 → 토란대_줄기 + 3. [수산맥] 수율90%%내외 박달홍게 프리미엄 선주직송, 1박스, 3kg (고급형10미) → 붉은대게 + """, request.title())); + + Prompt prompt = new Prompt(List.of(systemMessage, userMessage)); + String result = chatClient.prompt(prompt).call().content(); + + return RawMaterialAiResponse.builder() + .name(result) + .build(); + } +} + + diff --git a/src/main/java/com/webeye/backend/imageanalysis/presentation/ImageAnalysisController.java b/src/main/java/com/webeye/backend/imageanalysis/presentation/ImageAnalysisController.java new file mode 100644 index 0000000..0ae7075 --- /dev/null +++ b/src/main/java/com/webeye/backend/imageanalysis/presentation/ImageAnalysisController.java @@ -0,0 +1,27 @@ +package com.webeye.backend.imageanalysis.presentation; + +import com.webeye.backend.global.dto.response.SuccessResponse; +import com.webeye.backend.imageanalysis.application.ImageAnalysisService; +import com.webeye.backend.imageanalysis.dto.request.ImageAnalysisRequest; +import com.webeye.backend.imageanalysis.dto.response.ImageAnalysisResponse; +import com.webeye.backend.imageanalysis.presentation.swagger.ImageAnalysisSwagger; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import static com.webeye.backend.global.dto.response.type.SuccessCode.IMAGE_ANALYSIS_SUCCESS; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/image-analysis") +public class ImageAnalysisController implements ImageAnalysisSwagger { + private final ImageAnalysisService imageAnalysisService; + + @Override + @ResponseStatus(HttpStatus.OK) + @PostMapping(value = "") + public SuccessResponse imageAnalysis(@Valid @RequestBody ImageAnalysisRequest request) { + return SuccessResponse.of(IMAGE_ANALYSIS_SUCCESS, imageAnalysisService.analyzeImage(request)); + } +} diff --git a/src/main/java/com/webeye/backend/imageanalysis/presentation/swagger/ImageAnalysisSwagger.java b/src/main/java/com/webeye/backend/imageanalysis/presentation/swagger/ImageAnalysisSwagger.java new file mode 100644 index 0000000..0d48894 --- /dev/null +++ b/src/main/java/com/webeye/backend/imageanalysis/presentation/swagger/ImageAnalysisSwagger.java @@ -0,0 +1,27 @@ +package com.webeye.backend.imageanalysis.presentation.swagger; + +import com.webeye.backend.global.dto.response.SuccessResponse; +import com.webeye.backend.imageanalysis.dto.request.ImageAnalysisRequest; +import com.webeye.backend.imageanalysis.dto.response.ImageAnalysisResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "[이미지 분석]", description = "이미지 분석 관련 API") +public interface ImageAnalysisSwagger { + @Operation( + summary = "이미지 분석", + description = "이미지 URL을 입력받아 이미지를 분석을 제공합니다." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "이미지가 성공적으로 분석되었습니다." + ) + }) + SuccessResponse imageAnalysis( + @RequestBody ImageAnalysisRequest request + ); +} diff --git a/src/main/java/com/webeye/backend/nutrition/application/NutrientRecommendationService.java b/src/main/java/com/webeye/backend/nutrition/application/NutrientRecommendationService.java new file mode 100644 index 0000000..c78054a --- /dev/null +++ b/src/main/java/com/webeye/backend/nutrition/application/NutrientRecommendationService.java @@ -0,0 +1,60 @@ +package com.webeye.backend.nutrition.application; + +import com.webeye.backend.nutrition.domain.Nutrient; +import com.webeye.backend.nutrition.domain.NutrientRecommendation; +import com.webeye.backend.nutrition.dto.request.NutrientRecommendationRequest; +import com.webeye.backend.nutrition.dto.response.NutrientRecommendationResponse; +import com.webeye.backend.nutrition.persistent.NutrientRecommendationRepository; +import com.webeye.backend.product.domain.ProductNutrient; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Year; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NutrientRecommendationService { + private static final int ZERO_RECOMMENDATION = 100; + + private final NutrientRecommendationRepository recommendationRepository; + + @Transactional + public List analyzeNutrientSufficiency(NutrientRecommendationRequest request) { + List overRecommendationNutrients = new ArrayList<>(); + int userAge = Year.now().getValue() - request.birthYear(); + + for (ProductNutrient productNutrient : request.product().getNutrients()) { + Nutrient nutrient = productNutrient.getNutrient(); + + Optional recommendationOpt = recommendationRepository + .findByNutrientIdAndGenderAndAge(nutrient.getId(), request.gender(), userAge); + + if (recommendationOpt.isEmpty()) { + continue; + } + + NutrientRecommendation recommendation = recommendationOpt.get(); + double percentage = getPercentageOfRecommendation(recommendation.getAmount(), productNutrient.getAmount()); + + if (percentage >= 40.0) { + overRecommendationNutrients.add(NutrientRecommendationResponse.builder() + .nutrientType(productNutrient.getNutrient().getType()) + .percentage((int) percentage).build()); + } + } + return overRecommendationNutrients; + } + + private double getPercentageOfRecommendation(double recommendationAmount, double productAmount) { + if (recommendationAmount > 0) { + return productAmount / recommendationAmount * 100; + } + return ZERO_RECOMMENDATION; + } +} diff --git a/src/main/java/com/webeye/backend/nutrition/application/NutritionService.java b/src/main/java/com/webeye/backend/nutrition/application/NutritionService.java new file mode 100644 index 0000000..3832d1f --- /dev/null +++ b/src/main/java/com/webeye/backend/nutrition/application/NutritionService.java @@ -0,0 +1,78 @@ +package com.webeye.backend.nutrition.application; + +import com.webeye.backend.global.error.BusinessException; +import com.webeye.backend.global.error.ErrorCode; +import com.webeye.backend.imageanalysis.infrastructure.ImageUrlExtractor; +import com.webeye.backend.product.dto.request.FoodProductAnalysisRequest; +import com.webeye.backend.imageanalysis.infrastructure.OpenAiClient; +import com.webeye.backend.nutrition.domain.Nutrient; +import com.webeye.backend.nutrition.domain.type.NutrientType; +import com.webeye.backend.nutrition.dto.response.NutritionAiResponse; +import com.webeye.backend.nutrition.persistent.NutrientRepository; +import com.webeye.backend.product.domain.Product; +import com.webeye.backend.product.domain.ProductNutrient; +import com.webeye.backend.rawmaterial.application.RawMaterialService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.EnumMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NutritionService { + private final OpenAiClient openAiClient; + private final RawMaterialService rawMaterialService; + + private final NutrientRepository nutrientRepository; + + public Nutrient findByType(NutrientType type) { + return nutrientRepository.findByType(type) + .orElseThrow(() -> new BusinessException(ErrorCode.NUTRIENT_NOT_FOUND)); + } + + @Transactional + public void saveProductNutrition(Product product, FoodProductAnalysisRequest request) { + NutritionAiResponse response = openAiClient.explainNutrition(ImageUrlExtractor.extractImageUrlFromHtml(request.html())); + if (Boolean.TRUE.equals(response.isNutrientIncluded())) { + product.setNutrientReferenceAmount(response.nutrientReferenceAmount()); + Map nutrientMap = extractNutrientMap(response); + + nutrientMap.forEach((type, amount) -> { + if (amount == null) return; + Nutrient nutrient = findByType(type); + product.addNutrient( + ProductNutrient.builder() + .product(product) + .nutrient(nutrient) + .amount(amount) + .build() + ); + }); + return; + } + rawMaterialService.saveRawMaterialNutrition(product, request); + } + + private Map extractNutrientMap(NutritionAiResponse response) { + Map map = new EnumMap<>(NutrientType.class); + + map.put(NutrientType.SODIUM, response.sodium()); + map.put(NutrientType.CARBOHYDRATE, response.carbohydrate()); + map.put(NutrientType.SUGARS, response.sugars()); + map.put(NutrientType.FAT, response.fat()); + map.put(NutrientType.TRANS_FAT, response.transFat()); + map.put(NutrientType.SATURATED_FAT, response.saturatedFat()); + map.put(NutrientType.CHOLESTEROL, response.cholesterol()); + map.put(NutrientType.PROTEIN, response.protein()); + map.put(NutrientType.CALCIUM, response.calcium()); + map.put(NutrientType.PHOSPHORUS, response.phosphorus()); + map.put(NutrientType.NIACIN, response.niacin()); + map.put(NutrientType.VITAMIN_B, response.vitaminB()); + map.put(NutrientType.VITAMIN_E, response.vitaminE()); + + return map; + } +} diff --git a/src/main/java/com/webeye/backend/nutrition/domain/Nutrient.java b/src/main/java/com/webeye/backend/nutrition/domain/Nutrient.java new file mode 100644 index 0000000..6136e99 --- /dev/null +++ b/src/main/java/com/webeye/backend/nutrition/domain/Nutrient.java @@ -0,0 +1,23 @@ +package com.webeye.backend.nutrition.domain; + +import com.webeye.backend.nutrition.domain.type.NutrientType; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Nutrient { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, unique = true) + private NutrientType type; + + @Builder + public Nutrient(NutrientType type) { + this.type = type; + } +} \ No newline at end of file diff --git a/src/main/java/com/webeye/backend/nutrition/domain/NutrientRecommendation.java b/src/main/java/com/webeye/backend/nutrition/domain/NutrientRecommendation.java new file mode 100644 index 0000000..ecdc5ea --- /dev/null +++ b/src/main/java/com/webeye/backend/nutrition/domain/NutrientRecommendation.java @@ -0,0 +1,51 @@ +package com.webeye.backend.nutrition.domain; + +import com.webeye.backend.global.domain.BaseEntity; +import com.webeye.backend.nutrition.domain.type.Gender; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class NutrientRecommendation extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "nutrient_id", nullable = false) + private Nutrient nutrient; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Gender gender; + + @Column(nullable = false) + private int minAge; + + @Column(nullable = false) + private int maxAge; + + @Column(nullable = false) + private double amount; + + @Builder + public NutrientRecommendation( + Nutrient nutrient, + Gender gender, + int minAge, + int maxAge, + double amount + ) { + this.nutrient = nutrient; + this.gender = gender; + this.minAge = minAge; + this.maxAge = maxAge; + this.amount = amount; + } +} \ No newline at end of file diff --git a/src/main/java/com/webeye/backend/nutrition/domain/type/Gender.java b/src/main/java/com/webeye/backend/nutrition/domain/type/Gender.java new file mode 100644 index 0000000..1ea2e57 --- /dev/null +++ b/src/main/java/com/webeye/backend/nutrition/domain/type/Gender.java @@ -0,0 +1,6 @@ +package com.webeye.backend.nutrition.domain.type; + +public enum Gender { + MALE, + FEMALE +} \ No newline at end of file diff --git a/src/main/java/com/webeye/backend/nutrition/domain/type/NutrientType.java b/src/main/java/com/webeye/backend/nutrition/domain/type/NutrientType.java new file mode 100644 index 0000000..f09c871 --- /dev/null +++ b/src/main/java/com/webeye/backend/nutrition/domain/type/NutrientType.java @@ -0,0 +1,17 @@ +package com.webeye.backend.nutrition.domain.type; + +public enum NutrientType { + SODIUM, // 나트륨 + CARBOHYDRATE, // 탄수화물 + SUGARS, // 당류 + FAT, // 지방 + TRANS_FAT, // 트랜스지방 + SATURATED_FAT, // 포화지방 + CHOLESTEROL, // 콜레스테롤 + PROTEIN, // 단백질 + CALCIUM, // 칼슘 + PHOSPHORUS, // 인 + NIACIN, // 나이아신 + VITAMIN_B, // 비타민 B + VITAMIN_E // 비타민 E +} \ No newline at end of file diff --git a/src/main/java/com/webeye/backend/nutrition/dto/request/NutrientRecommendationRequest.java b/src/main/java/com/webeye/backend/nutrition/dto/request/NutrientRecommendationRequest.java new file mode 100644 index 0000000..c9181da --- /dev/null +++ b/src/main/java/com/webeye/backend/nutrition/dto/request/NutrientRecommendationRequest.java @@ -0,0 +1,13 @@ +package com.webeye.backend.nutrition.dto.request; + +import com.webeye.backend.nutrition.domain.type.Gender; +import com.webeye.backend.product.domain.Product; +import lombok.Builder; + +@Builder +public record NutrientRecommendationRequest( + Product product, + int birthYear, + Gender gender +) { +} diff --git a/src/main/java/com/webeye/backend/nutrition/dto/response/NutrientRecommendationResponse.java b/src/main/java/com/webeye/backend/nutrition/dto/response/NutrientRecommendationResponse.java new file mode 100644 index 0000000..cabe5d9 --- /dev/null +++ b/src/main/java/com/webeye/backend/nutrition/dto/response/NutrientRecommendationResponse.java @@ -0,0 +1,16 @@ +package com.webeye.backend.nutrition.dto.response; + +import com.webeye.backend.nutrition.domain.type.NutrientType; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Schema(description = "영양성분 권장량에 대한 비율 응답") +@Builder +public record NutrientRecommendationResponse( + @Schema(description = "영양성분 이름") + NutrientType nutrientType, + + @Schema(description = "권장량에 대한 영양성분의 비율") + int percentage +) { +} diff --git a/src/main/java/com/webeye/backend/nutrition/dto/response/NutrientResponse.java b/src/main/java/com/webeye/backend/nutrition/dto/response/NutrientResponse.java new file mode 100644 index 0000000..df57465 --- /dev/null +++ b/src/main/java/com/webeye/backend/nutrition/dto/response/NutrientResponse.java @@ -0,0 +1,16 @@ +package com.webeye.backend.nutrition.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +public record NutrientResponse( + @Schema(description = "영양성분 도출 기준 함량(단위: g)") + Integer nutrientReferenceAmount, + + @Schema(description = "영양성분 권장량을 넘는 성분") + List overRecommendationNutrients +) { +} diff --git a/src/main/java/com/webeye/backend/nutrition/dto/response/NutritionAiResponse.java b/src/main/java/com/webeye/backend/nutrition/dto/response/NutritionAiResponse.java new file mode 100644 index 0000000..476aebb --- /dev/null +++ b/src/main/java/com/webeye/backend/nutrition/dto/response/NutritionAiResponse.java @@ -0,0 +1,41 @@ +package com.webeye.backend.nutrition.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Schema(description = "제품 영양 정보 응답") +@Builder +public record NutritionAiResponse( + @Schema(description = "영양성분 정보 포함 여부") + Boolean isNutrientIncluded, + @Schema(description = "영양성분 도출 기준 함량") + Integer nutrientReferenceAmount, + + @Schema(description = "나트륨 (mg)", example = "120.5") + Double sodium, + @Schema(description = "탄수화물 (g)", example = "25.0") + Double carbohydrate, + @Schema(description = "당류 (g)", example = "10.2") + Double sugars, + @Schema(description = "지방 (g)", example = "5.5") + Double fat, + @Schema(description = "트랜스지방 (g)", example = "0.0") + Double transFat, + @Schema(description = "포화지방 (g)", example = "1.2") + Double saturatedFat, + @Schema(description = "콜레스테롤 (mg)", example = "10.0") + Double cholesterol, + @Schema(description = "단백질 (g)", example = "8.5") + Double protein, + @Schema(description = "칼슘 (mg)", example = "200.0") + Double calcium, + @Schema(description = "인 (mg)", example = "150.0") + Double phosphorus, + @Schema(description = "나이아신 (mg)", example = "2.5") + Double niacin, + @Schema(description = "비타민 B (mg)", example = "1.5") + Double vitaminB, + @Schema(description = "비타민 E (mg)", example = "3.0") + Double vitaminE +) { +} \ No newline at end of file diff --git a/src/main/java/com/webeye/backend/nutrition/persistent/NutrientRecommendationRepository.java b/src/main/java/com/webeye/backend/nutrition/persistent/NutrientRecommendationRepository.java new file mode 100644 index 0000000..891eceb --- /dev/null +++ b/src/main/java/com/webeye/backend/nutrition/persistent/NutrientRecommendationRepository.java @@ -0,0 +1,24 @@ +package com.webeye.backend.nutrition.persistent; + +import com.webeye.backend.nutrition.domain.Nutrient; +import com.webeye.backend.nutrition.domain.NutrientRecommendation; +import com.webeye.backend.nutrition.domain.type.Gender; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface NutrientRecommendationRepository extends JpaRepository { + + @Query("SELECT r FROM NutrientRecommendation r " + + "WHERE r.nutrient.id = :nutrientId " + + "AND r.gender = :gender " + + "AND :age BETWEEN r.minAge AND r.maxAge") + Optional findByNutrientIdAndGenderAndAge( + @Param("nutrientId") Long nutrientId, + @Param("gender") Gender gender, + @Param("age") int age); +} diff --git a/src/main/java/com/webeye/backend/nutrition/persistent/NutrientRepository.java b/src/main/java/com/webeye/backend/nutrition/persistent/NutrientRepository.java new file mode 100644 index 0000000..fe6ba38 --- /dev/null +++ b/src/main/java/com/webeye/backend/nutrition/persistent/NutrientRepository.java @@ -0,0 +1,13 @@ +package com.webeye.backend.nutrition.persistent; + +import com.webeye.backend.nutrition.domain.Nutrient; +import com.webeye.backend.nutrition.domain.type.NutrientType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface NutrientRepository extends JpaRepository { + Optional findByType(NutrientType type); +} diff --git a/src/main/java/com/webeye/backend/nutrition/persistent/init/NutrientInit.java b/src/main/java/com/webeye/backend/nutrition/persistent/init/NutrientInit.java new file mode 100644 index 0000000..78b382a --- /dev/null +++ b/src/main/java/com/webeye/backend/nutrition/persistent/init/NutrientInit.java @@ -0,0 +1,31 @@ +package com.webeye.backend.nutrition.persistent.init; + +import com.webeye.backend.global.util.DummyDataInit; +import com.webeye.backend.nutrition.domain.Nutrient; +import com.webeye.backend.nutrition.domain.type.NutrientType; +import com.webeye.backend.nutrition.persistent.NutrientRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.annotation.Order; + +@Slf4j +@RequiredArgsConstructor +@Order(1) +@DummyDataInit +public class NutrientInit implements ApplicationRunner { + private final NutrientRepository nutrientRepository; + + @Override + public void run(ApplicationArguments args) { + for (NutrientType nutrientType : NutrientType.values()) { + if (nutrientRepository.findByType(nutrientType).isEmpty()) { + Nutrient nutrient = Nutrient.builder() + .type(nutrientType) + .build(); + nutrientRepository.save(nutrient); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/com/webeye/backend/nutrition/persistent/init/NutrientRecommendationInit.java b/src/main/java/com/webeye/backend/nutrition/persistent/init/NutrientRecommendationInit.java new file mode 100644 index 0000000..8279b9c --- /dev/null +++ b/src/main/java/com/webeye/backend/nutrition/persistent/init/NutrientRecommendationInit.java @@ -0,0 +1,302 @@ +package com.webeye.backend.nutrition.persistent.init; + +import com.webeye.backend.global.error.BusinessException; +import com.webeye.backend.global.error.ErrorCode; +import com.webeye.backend.global.util.DummyDataInit; +import com.webeye.backend.nutrition.domain.Nutrient; +import com.webeye.backend.nutrition.domain.NutrientRecommendation; +import com.webeye.backend.nutrition.domain.type.Gender; +import com.webeye.backend.nutrition.domain.type.NutrientType; +import com.webeye.backend.nutrition.persistent.NutrientRecommendationRepository; +import com.webeye.backend.nutrition.persistent.NutrientRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.annotation.Order; + +@Slf4j +@RequiredArgsConstructor +@Order(2) +@DummyDataInit +public class NutrientRecommendationInit implements ApplicationRunner { + + private final NutrientRecommendationRepository recommendationRepository; + private final NutrientRepository nutrientRepository; + + @Override + public void run(ApplicationArguments args) { + if (recommendationRepository.count() > 0) { + return; + } + + Nutrient carbohydrate = nutrientRepository.findByType(NutrientType.CARBOHYDRATE) + .orElseThrow(() -> new BusinessException(ErrorCode.NUTRIENT_NOT_FOUND)); + Nutrient protein = nutrientRepository.findByType(NutrientType.PROTEIN) + .orElseThrow(() -> new BusinessException(ErrorCode.NUTRIENT_NOT_FOUND)); + Nutrient calcium = nutrientRepository.findByType(NutrientType.CALCIUM) + .orElseThrow(() -> new BusinessException(ErrorCode.NUTRIENT_NOT_FOUND)); + Nutrient sodium = nutrientRepository.findByType(NutrientType.SODIUM) + .orElseThrow(() -> new BusinessException(ErrorCode.NUTRIENT_NOT_FOUND)); + Nutrient vitaminE = nutrientRepository.findByType(NutrientType.VITAMIN_E) + .orElseThrow(() -> new BusinessException(ErrorCode.NUTRIENT_NOT_FOUND)); + Nutrient vitaminB = nutrientRepository.findByType(NutrientType.VITAMIN_B) + .orElseThrow(() -> new BusinessException(ErrorCode.NUTRIENT_NOT_FOUND)); + Nutrient fat = nutrientRepository.findByType(NutrientType.FAT) + .orElseThrow(() -> new BusinessException(ErrorCode.NUTRIENT_NOT_FOUND)); + Nutrient transFat = nutrientRepository.findByType(NutrientType.TRANS_FAT) + .orElseThrow(() -> new BusinessException(ErrorCode.NUTRIENT_NOT_FOUND)); + Nutrient saturatedFat = nutrientRepository.findByType(NutrientType.SATURATED_FAT) + .orElseThrow(() -> new BusinessException(ErrorCode.NUTRIENT_NOT_FOUND)); + + // 탄수화물 권장 섭취량 + saveRecommendation(carbohydrate, Gender.MALE, 0, 0, 75); + saveRecommendation(carbohydrate, Gender.MALE, 1, 2, 130); + saveRecommendation(carbohydrate, Gender.MALE, 3, 5, 130); + saveRecommendation(carbohydrate, Gender.MALE, 6, 8, 130); + saveRecommendation(carbohydrate, Gender.MALE, 9, 11, 130); + saveRecommendation(carbohydrate, Gender.MALE, 12, 14, 130); + saveRecommendation(carbohydrate, Gender.MALE, 15, 18, 130); + saveRecommendation(carbohydrate, Gender.MALE, 19, 29, 130); + saveRecommendation(carbohydrate, Gender.MALE, 30, 49, 130); + saveRecommendation(carbohydrate, Gender.MALE, 50, 64, 130); + saveRecommendation(carbohydrate, Gender.MALE, 65, 74, 130); + saveRecommendation(carbohydrate, Gender.MALE, 75, 200, 130); + + saveRecommendation(carbohydrate, Gender.FEMALE, 0, 0, 75); + saveRecommendation(carbohydrate, Gender.FEMALE, 1, 2, 130); + saveRecommendation(carbohydrate, Gender.FEMALE, 3, 5, 130); + saveRecommendation(carbohydrate, Gender.FEMALE, 6, 8, 130); + saveRecommendation(carbohydrate, Gender.FEMALE, 9, 11, 130); + saveRecommendation(carbohydrate, Gender.FEMALE, 12, 14, 130); + saveRecommendation(carbohydrate, Gender.FEMALE, 15, 18, 130); + saveRecommendation(carbohydrate, Gender.FEMALE, 19, 29, 130); + saveRecommendation(carbohydrate, Gender.FEMALE, 30, 49, 130); + saveRecommendation(carbohydrate, Gender.FEMALE, 50, 64, 130); + saveRecommendation(carbohydrate, Gender.FEMALE, 65, 74, 130); + saveRecommendation(carbohydrate, Gender.FEMALE, 75, 200, 130); + + + // 단백질 권장 섭취량 + saveRecommendation(protein, Gender.MALE, 0, 0, 12.5); + saveRecommendation(protein, Gender.MALE, 1, 2, 20); + saveRecommendation(protein, Gender.MALE, 3, 5, 25); + saveRecommendation(protein, Gender.MALE, 6, 8, 35); + saveRecommendation(protein, Gender.MALE, 9, 11, 50); + saveRecommendation(protein, Gender.MALE, 12, 14, 60); + saveRecommendation(protein, Gender.MALE, 15, 18, 65); + saveRecommendation(protein, Gender.MALE, 19, 29, 65); + saveRecommendation(protein, Gender.MALE, 30, 49, 65); + saveRecommendation(protein, Gender.MALE, 50, 64, 60); + saveRecommendation(protein, Gender.MALE, 65, 74, 60); + saveRecommendation(protein, Gender.MALE, 75, 200, 60); + + saveRecommendation(protein, Gender.FEMALE, 0, 0, 12.5); + saveRecommendation(protein, Gender.FEMALE, 1, 2, 20); + saveRecommendation(protein, Gender.FEMALE, 3, 5, 25); + saveRecommendation(protein, Gender.FEMALE, 6, 8, 35); + saveRecommendation(protein, Gender.FEMALE, 9, 11, 45); + saveRecommendation(protein, Gender.FEMALE, 12, 14, 55); + saveRecommendation(protein, Gender.FEMALE, 15, 18, 55); + saveRecommendation(protein, Gender.FEMALE, 19, 29, 55); + saveRecommendation(protein, Gender.FEMALE, 30, 49, 50); + saveRecommendation(protein, Gender.FEMALE, 50, 64, 50); + saveRecommendation(protein, Gender.FEMALE, 65, 74, 50); + saveRecommendation(protein, Gender.FEMALE, 75, 200, 50); + + + // 칼슘 권장 섭취량 + saveRecommendation(calcium, Gender.MALE, 0, 0, 275); + saveRecommendation(calcium, Gender.MALE, 1, 2, 500); + saveRecommendation(calcium, Gender.MALE, 3, 5, 600); + saveRecommendation(calcium, Gender.MALE, 6, 8, 700); + saveRecommendation(calcium, Gender.MALE, 9, 11, 800); + saveRecommendation(calcium, Gender.MALE, 12, 14, 1000); + saveRecommendation(calcium, Gender.MALE, 15, 18, 900); + saveRecommendation(calcium, Gender.MALE, 19, 29, 800); + saveRecommendation(calcium, Gender.MALE, 30, 49, 800); + saveRecommendation(calcium, Gender.MALE, 50, 64, 750); + saveRecommendation(calcium, Gender.MALE, 65, 74, 700); + saveRecommendation(calcium, Gender.MALE, 75, 200, 700); + + saveRecommendation(calcium, Gender.FEMALE, 0, 0, 275); + saveRecommendation(calcium, Gender.FEMALE, 1, 2, 500); + saveRecommendation(calcium, Gender.FEMALE, 3, 5, 600); + saveRecommendation(calcium, Gender.FEMALE, 6, 8, 700); + saveRecommendation(calcium, Gender.FEMALE, 9, 11, 800); + saveRecommendation(calcium, Gender.FEMALE, 12, 14, 900); + saveRecommendation(calcium, Gender.FEMALE, 15, 18, 800); + saveRecommendation(calcium, Gender.FEMALE, 19, 29, 700); + saveRecommendation(calcium, Gender.FEMALE, 30, 49, 700); + saveRecommendation(calcium, Gender.FEMALE, 50, 64, 800); + saveRecommendation(calcium, Gender.FEMALE, 65, 74, 800); + saveRecommendation(calcium, Gender.FEMALE, 75, 200, 800); + + + // 나트륨 권장 섭취량 + saveRecommendation(sodium, Gender.MALE, 0, 0, 240); + saveRecommendation(sodium, Gender.MALE, 1, 2, 810); + saveRecommendation(sodium, Gender.MALE, 3, 5, 1000); + saveRecommendation(sodium, Gender.MALE, 6, 8, 1200); + saveRecommendation(sodium, Gender.MALE, 9, 11, 1500); + saveRecommendation(sodium, Gender.MALE, 12, 14, 1500); + saveRecommendation(sodium, Gender.MALE, 15, 18, 1500); + saveRecommendation(sodium, Gender.MALE, 19, 29, 1500); + saveRecommendation(sodium, Gender.MALE, 30, 49, 1500); + saveRecommendation(sodium, Gender.MALE, 50, 64, 1500); + saveRecommendation(sodium, Gender.MALE, 65, 74, 1300); + saveRecommendation(sodium, Gender.MALE, 75, 200, 1100); + + saveRecommendation(sodium, Gender.FEMALE, 0, 0, 240); + saveRecommendation(sodium, Gender.FEMALE, 1, 2, 810); + saveRecommendation(sodium, Gender.FEMALE, 3, 5, 1000); + saveRecommendation(sodium, Gender.FEMALE, 6, 8, 1200); + saveRecommendation(sodium, Gender.FEMALE, 9, 11, 1500); + saveRecommendation(sodium, Gender.FEMALE, 12, 14, 1500); + saveRecommendation(sodium, Gender.FEMALE, 15, 18, 1500); + saveRecommendation(sodium, Gender.FEMALE, 19, 29, 1500); + saveRecommendation(sodium, Gender.FEMALE, 30, 49, 1500); + saveRecommendation(sodium, Gender.FEMALE, 50, 64, 1500); + saveRecommendation(sodium, Gender.FEMALE, 65, 74, 1300); + saveRecommendation(sodium, Gender.FEMALE, 75, 200, 1100); + + + // 비타민 E 권장 섭취량 + saveRecommendation(vitaminE, Gender.MALE, 0, 0, 3.5); + saveRecommendation(vitaminE, Gender.MALE, 1, 2, 5); + saveRecommendation(vitaminE, Gender.MALE, 3, 5, 6); + saveRecommendation(vitaminE, Gender.MALE, 6, 8, 7); + saveRecommendation(vitaminE, Gender.MALE, 9, 11, 9); + saveRecommendation(vitaminE, Gender.MALE, 12, 14, 11); + saveRecommendation(vitaminE, Gender.MALE, 15, 18, 12); + saveRecommendation(vitaminE, Gender.MALE, 19, 29, 12); + saveRecommendation(vitaminE, Gender.MALE, 30, 49, 12); + saveRecommendation(vitaminE, Gender.MALE, 50, 64, 12); + saveRecommendation(vitaminE, Gender.MALE, 65, 74, 12); + saveRecommendation(vitaminE, Gender.MALE, 75, 200, 12); + + saveRecommendation(vitaminE, Gender.FEMALE, 0, 0, 3.5); + saveRecommendation(vitaminE, Gender.FEMALE, 1, 2, 5); + saveRecommendation(vitaminE, Gender.FEMALE, 3, 5, 6); + saveRecommendation(vitaminE, Gender.FEMALE, 6, 8, 7); + saveRecommendation(vitaminE, Gender.FEMALE, 9, 11, 9); + saveRecommendation(vitaminE, Gender.FEMALE, 12, 14, 11); + saveRecommendation(vitaminE, Gender.FEMALE, 15, 18, 12); + saveRecommendation(vitaminE, Gender.FEMALE, 19, 29, 12); + saveRecommendation(vitaminE, Gender.FEMALE, 30, 49, 12); + saveRecommendation(vitaminE, Gender.FEMALE, 50, 64, 12); + saveRecommendation(vitaminE, Gender.FEMALE, 65, 74, 12); + saveRecommendation(vitaminE, Gender.FEMALE, 75, 200, 12); + + + // 비타민 B 권장 섭취량 + saveRecommendation(vitaminB, Gender.MALE, 0, 0, 0.2); + saveRecommendation(vitaminB, Gender.MALE, 1, 2, 20); + saveRecommendation(vitaminB, Gender.MALE, 3, 5, 30); + saveRecommendation(vitaminB, Gender.MALE, 6, 8, 45); + saveRecommendation(vitaminB, Gender.MALE, 9, 11, 60); + saveRecommendation(vitaminB, Gender.MALE, 12, 14, 80); + saveRecommendation(vitaminB, Gender.MALE, 15, 18, 95); + saveRecommendation(vitaminB, Gender.MALE, 19, 200, 100); + + saveRecommendation(vitaminB, Gender.FEMALE, 0, 0, 0.2); + saveRecommendation(vitaminB, Gender.FEMALE, 1, 2, 20); + saveRecommendation(vitaminB, Gender.FEMALE, 3, 5, 30); + saveRecommendation(vitaminB, Gender.FEMALE, 6, 8, 45); + saveRecommendation(vitaminB, Gender.FEMALE, 9, 11, 60); + saveRecommendation(vitaminB, Gender.FEMALE, 12, 14, 80); + saveRecommendation(vitaminB, Gender.FEMALE, 15, 18, 95); + saveRecommendation(vitaminB, Gender.FEMALE, 19, 200, 100); + + + // 지방 권장 섭취량 + saveRecommendation(fat, Gender.MALE, 1, 2, 27.5); + saveRecommendation(fat, Gender.MALE, 3, 5, 40); + saveRecommendation(fat, Gender.MALE, 6, 8, 47.5); + saveRecommendation(fat, Gender.MALE, 9, 11, 57.5); + saveRecommendation(fat, Gender.MALE, 12, 14, 72.5); + saveRecommendation(fat, Gender.MALE, 15, 18, 80.0); + saveRecommendation(fat, Gender.MALE, 19, 29, 72.5); + saveRecommendation(fat, Gender.MALE, 30, 49, 70.0); + saveRecommendation(fat, Gender.MALE, 50, 64, 62.5); + saveRecommendation(fat, Gender.MALE, 65, 74, 57.5); + saveRecommendation(fat, Gender.MALE, 75, 200, 55.0); + + saveRecommendation(fat, Gender.FEMALE, 1, 2, 27.5); + saveRecommendation(fat, Gender.FEMALE, 3, 5, 40); + saveRecommendation(fat, Gender.FEMALE, 6, 8, 42.5); + saveRecommendation(fat, Gender.FEMALE, 9, 11, 50.0); + saveRecommendation(fat, Gender.FEMALE, 12, 14, 57.5); + saveRecommendation(fat, Gender.FEMALE, 15, 18, 57.5); + saveRecommendation(fat, Gender.FEMALE, 19, 29, 57.5); + saveRecommendation(fat, Gender.FEMALE, 30, 49, 55.0); + saveRecommendation(fat, Gender.FEMALE, 50, 64, 50.0); + saveRecommendation(fat, Gender.FEMALE, 65, 74, 45.0); + saveRecommendation(fat, Gender.FEMALE, 75, 200, 42.5); + + + // 트랜스 지방 권장 섭취량 + saveRecommendation(transFat, Gender.MALE, 0, 2, 0); + saveRecommendation(transFat, Gender.MALE, 3, 5, 1.78); + saveRecommendation(transFat, Gender.MALE, 6, 8, 2.11); + saveRecommendation(transFat, Gender.MALE, 9, 11, 2.56); + saveRecommendation(transFat, Gender.MALE, 12, 14, 3.22); + saveRecommendation(transFat, Gender.MALE, 15, 18, 3.56); + saveRecommendation(transFat, Gender.MALE, 19, 29, 3.22); + saveRecommendation(transFat, Gender.MALE, 30, 49, 3.11); + saveRecommendation(transFat, Gender.MALE, 50, 64, 2.78); + saveRecommendation(transFat, Gender.MALE, 65, 74, 2.56); + saveRecommendation(transFat, Gender.MALE, 75, 200, 2.44); + + saveRecommendation(transFat, Gender.FEMALE, 0, 2, 0); + saveRecommendation(transFat, Gender.FEMALE, 3, 5, 1.78); + saveRecommendation(transFat, Gender.FEMALE, 6, 8, 1.89); + saveRecommendation(transFat, Gender.FEMALE, 9, 11, 2.22); + saveRecommendation(transFat, Gender.FEMALE, 12, 14, 2.56); + saveRecommendation(transFat, Gender.FEMALE, 15, 18, 2.56); + saveRecommendation(transFat, Gender.FEMALE, 19, 29, 2.56); + saveRecommendation(transFat, Gender.FEMALE, 30, 49, 2.44); + saveRecommendation(transFat, Gender.FEMALE, 50, 64, 2.22); + saveRecommendation(transFat, Gender.FEMALE, 65, 74, 2.00); + saveRecommendation(transFat, Gender.FEMALE, 75, 200, 1.89); + + + // 포화 지방 권장 섭취량 + saveRecommendation(saturatedFat, Gender.MALE, 0, 2, 0); + saveRecommendation(saturatedFat, Gender.MALE, 3, 5, 14.22); + saveRecommendation(saturatedFat, Gender.MALE, 6, 8, 16.89); + saveRecommendation(saturatedFat, Gender.MALE, 9, 11, 20.44); + saveRecommendation(saturatedFat, Gender.MALE, 12, 14, 25.78); + saveRecommendation(saturatedFat, Gender.MALE, 15, 18, 28.44); + saveRecommendation(saturatedFat, Gender.MALE, 19, 29, 22.56); + saveRecommendation(saturatedFat, Gender.MALE, 30, 49, 21.78); + saveRecommendation(saturatedFat, Gender.MALE, 50, 64, 19.44); + saveRecommendation(saturatedFat, Gender.MALE, 65, 74, 17.89); + saveRecommendation(saturatedFat, Gender.MALE, 75, 200, 17.11); + + saveRecommendation(saturatedFat, Gender.FEMALE, 0, 2, 0); + saveRecommendation(saturatedFat, Gender.FEMALE, 3, 5, 14.22); + saveRecommendation(saturatedFat, Gender.FEMALE, 6, 8, 15.11); + saveRecommendation(saturatedFat, Gender.FEMALE, 9, 11, 17.78); + saveRecommendation(saturatedFat, Gender.FEMALE, 12, 14, 20.44); + saveRecommendation(saturatedFat, Gender.FEMALE, 15, 18, 20.44); + saveRecommendation(saturatedFat, Gender.FEMALE, 19, 29, 17.89); + saveRecommendation(saturatedFat, Gender.FEMALE, 30, 49, 17.11); + saveRecommendation(saturatedFat, Gender.FEMALE, 50, 64, 15.56); + saveRecommendation(saturatedFat, Gender.FEMALE, 65, 74, 14.00); + saveRecommendation(saturatedFat, Gender.FEMALE, 75, 200, 13.22); + + } + + private void saveRecommendation(Nutrient nutrient, Gender gender, int minAge, int maxAge, double amount) { + NutrientRecommendation recommendation = NutrientRecommendation.builder() + .nutrient(nutrient) + .gender(gender) + .minAge(minAge) + .maxAge(maxAge) + .amount(amount) + .build(); + + recommendationRepository.save(recommendation); + } +} \ No newline at end of file diff --git a/src/main/java/com/webeye/backend/product/application/ProductService.java b/src/main/java/com/webeye/backend/product/application/ProductService.java new file mode 100644 index 0000000..0b52f58 --- /dev/null +++ b/src/main/java/com/webeye/backend/product/application/ProductService.java @@ -0,0 +1,78 @@ +package com.webeye.backend.product.application; + +import com.webeye.backend.allergy.application.AllergyService; +import com.webeye.backend.allergy.type.AllergyType; +import com.webeye.backend.nutrition.dto.response.NutrientResponse; +import com.webeye.backend.product.domain.type.ProductType; +import com.webeye.backend.global.error.BusinessException; +import com.webeye.backend.global.error.ErrorCode; +import com.webeye.backend.nutrition.application.NutrientRecommendationService; +import com.webeye.backend.nutrition.dto.request.NutrientRecommendationRequest; +import com.webeye.backend.product.domain.ProductAllergy; +import com.webeye.backend.product.dto.request.FoodProductAnalysisRequest; +import com.webeye.backend.nutrition.application.NutritionService; +import com.webeye.backend.product.domain.Product; +import com.webeye.backend.product.dto.response.ProductResponse; +import com.webeye.backend.product.persistent.ProductRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ProductService { + private final NutritionService nutritionService; + private final NutrientRecommendationService nutrientRecommendationService; + private final AllergyService allergyService; + + private final ProductRepository productRepository; + + @Transactional + public ProductResponse analyzeFoodProduct(FoodProductAnalysisRequest request) { + log.info("[ProductService] 📌 requested product ID: {}", request.productId()); + if (productRepository.existsById(request.productId())) { + Product product = productRepository.findById(request.productId()) + .orElseThrow(() -> new BusinessException(ErrorCode.PRODUCT_NOT_FOUND)); + + return ProductResponse.builder() + .allergyTypes(getAllergyResponse(product, request.allergies())) + .nutrientResponse(getNutrientResponse(request, product)) + .build(); + } + Product product = Product.builder() + .id(request.productId()) + .productType(ProductType.FOOD) + .build(); + productRepository.save(product); + + nutritionService.saveProductNutrition(product, request); + allergyService.saveProductAllergy(product, request); + + return ProductResponse.builder() + .allergyTypes(getAllergyResponse(product, request.allergies())) + .nutrientResponse(getNutrientResponse(request, product)) + .build(); + } + + private List getAllergyResponse(Product product, List userAllergies) { + return product.getAllergies().stream() + .map(ProductAllergy::getAllergy) + .distinct() + .filter(userAllergies::contains) + .toList(); + } + + private NutrientResponse getNutrientResponse( + FoodProductAnalysisRequest request, Product product) { + return NutrientResponse.builder() + .nutrientReferenceAmount(product.getNutrientReferenceAmount()) + .overRecommendationNutrients(nutrientRecommendationService.analyzeNutrientSufficiency(NutrientRecommendationRequest + .builder().birthYear(request.birthYear()).gender(request.gender()).product(product).build())) + .build(); + } +} diff --git a/src/main/java/com/webeye/backend/product/domain/Product.java b/src/main/java/com/webeye/backend/product/domain/Product.java new file mode 100644 index 0000000..93e7f07 --- /dev/null +++ b/src/main/java/com/webeye/backend/product/domain/Product.java @@ -0,0 +1,91 @@ +package com.webeye.backend.product.domain; + +import com.webeye.backend.cosmetic.domain.CosmeticIngredient; +import com.webeye.backend.global.domain.BaseEntity; +import com.webeye.backend.product.domain.type.ProductType; +import com.webeye.backend.productdetail.domain.ProductDetail; +import com.webeye.backend.review.domain.Review; +import jakarta.persistence.*; +import lombok.*; + +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Product extends BaseEntity { + @Id + @Column(name = "product_id", nullable = false) + private String id; // 쿠팡에서 products 뒤에 오는 숫자 + + private Integer nutrientReferenceAmount; + + @Enumerated(EnumType.STRING) + @Column(nullable = true) + private ProductType productType; + + @OneToOne(mappedBy = "product", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) + private Review review; + + @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true) + private List cosmeticIngredients = new ArrayList<>(); + + @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true) + private List allergies = new ArrayList<>(); + + @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true) + private List nutrients = new ArrayList<>(); + + @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true) + private List healthFoods = new ArrayList<>(); + + @OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true) + private List details = new ArrayList<>(); + + @Builder + public Product( + String id, + ProductType productType, + Integer nutrientReferenceAmount + ) { + this.id = id; + this.productType = productType; + this.nutrientReferenceAmount = nutrientReferenceAmount; + } + + public void associateWithReview(Review review) { + this.review = review; + if (review.getProduct() != this) { + review.associateWithProduct(this); + } + } + + public void addNutrient(ProductNutrient nutrient) { + this.nutrients.add(nutrient); + nutrient.associateWithProduct(this); + } + + public void addAllergy(ProductAllergy allergy) { + this.allergies.add(allergy); + allergy.associateWithProduct(this); + } + + public void addHealthFood(ProductHealthfood healthFood) { + this.healthFoods.add(healthFood); + healthFood.associateWithProduct(this); + } + + public void addProductDetail(ProductDetail detail) { + this.details.add(detail); + detail.associateWithProduct(this); + } + + public void addProductDetails(List details) { + details.forEach(this::addProductDetail); + } + + public void setNutrientReferenceAmount(Integer nutrientReferenceAmount) { + this.nutrientReferenceAmount = nutrientReferenceAmount; + } +} \ No newline at end of file diff --git a/src/main/java/com/webeye/backend/product/domain/ProductAllergy.java b/src/main/java/com/webeye/backend/product/domain/ProductAllergy.java new file mode 100644 index 0000000..fd36e51 --- /dev/null +++ b/src/main/java/com/webeye/backend/product/domain/ProductAllergy.java @@ -0,0 +1,36 @@ +package com.webeye.backend.product.domain; + +import com.webeye.backend.allergy.type.AllergyType; +import com.webeye.backend.global.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductAllergy extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id") + private Product product; + + @Enumerated(EnumType.STRING) + private AllergyType allergy; + + @Builder + public ProductAllergy(Product product, AllergyType allergy) { + this.product = product; + this.allergy = allergy; + } + + public void associateWithProduct(Product product) { + this.product = product; + product.getAllergies().add(this); + } +} diff --git a/src/main/java/com/webeye/backend/product/domain/ProductHealthfood.java b/src/main/java/com/webeye/backend/product/domain/ProductHealthfood.java new file mode 100644 index 0000000..a09a80d --- /dev/null +++ b/src/main/java/com/webeye/backend/product/domain/ProductHealthfood.java @@ -0,0 +1,38 @@ +package com.webeye.backend.product.domain; + +import com.webeye.backend.global.domain.BaseEntity; +import com.webeye.backend.healthfood.domain.HealthFood; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductHealthfood extends BaseEntity { + + @Id + @Column(name = "product_health_food_id", nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id") + private Product product; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "health_food_id") + private HealthFood healthFood; + + @Builder + public ProductHealthfood(Product product, HealthFood healthFood) { + this.product = product; + this.healthFood = healthFood; + } + + public void associateWithProduct(Product product) { + this.product = product; + } +} diff --git a/src/main/java/com/webeye/backend/product/domain/ProductNutrient.java b/src/main/java/com/webeye/backend/product/domain/ProductNutrient.java new file mode 100644 index 0000000..f6779b3 --- /dev/null +++ b/src/main/java/com/webeye/backend/product/domain/ProductNutrient.java @@ -0,0 +1,37 @@ +package com.webeye.backend.product.domain; + +import com.webeye.backend.global.domain.BaseEntity; +import com.webeye.backend.nutrition.domain.Nutrient; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ProductNutrient extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id") + private Product product; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "nutrient_id") + private Nutrient nutrient; + + @Column(nullable = false) + private Double amount; // 영양소 함량 + + @Builder + public ProductNutrient(Product product, Nutrient nutrient, Double amount) { + this.product = product; + this.nutrient = nutrient; + this.amount = amount; + } + + public void associateWithProduct(Product product) { + this.product = product; + } +} \ No newline at end of file diff --git a/src/main/java/com/webeye/backend/product/domain/type/ProductType.java b/src/main/java/com/webeye/backend/product/domain/type/ProductType.java new file mode 100644 index 0000000..c7aeba7 --- /dev/null +++ b/src/main/java/com/webeye/backend/product/domain/type/ProductType.java @@ -0,0 +1,7 @@ +package com.webeye.backend.product.domain.type; + +public enum ProductType { + FOOD, + COSMETIC, + HEALTH_FOOD +} diff --git a/src/main/java/com/webeye/backend/product/dto/request/FoodProductAnalysisRequest.java b/src/main/java/com/webeye/backend/product/dto/request/FoodProductAnalysisRequest.java new file mode 100644 index 0000000..cabd507 --- /dev/null +++ b/src/main/java/com/webeye/backend/product/dto/request/FoodProductAnalysisRequest.java @@ -0,0 +1,39 @@ +package com.webeye.backend.product.dto.request; + +import com.webeye.backend.allergy.type.AllergyType; +import com.webeye.backend.nutrition.domain.type.Gender; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import lombok.Builder; + +import java.util.List; + +@Schema(description = "사용자 정보 기반 식품 분석") +@Builder +public record FoodProductAnalysisRequest( + @NotEmpty(message = "제품 ID는 비어있을 수 없습니다.") + String productId, + + @Schema(description = "상품 제목") + String title, + + @Schema(description = "상품 상세 정보 HTML") + @NotEmpty(message = "상품 상세 정보의 HTML은 비어있을 수 없습니다.") + @Pattern(regexp = ".*.*", message = "HTML에는 최소한 하나의 이미지 태그가 포함되어야 합니다.") + String html, + + @Schema(description = "사용자 출생년도") + @NotNull(message = "사용자의 출생연도는 비어있을 수 없습니다.") + Integer birthYear, + + @Schema(description = "사용자 성별") + @NotNull(message = "사용자의 성별은 비어있을 수 없습니다.") + Gender gender, + + @Schema(description = "사용자 알레르기") + List allergies +) { +} + diff --git a/src/main/java/com/webeye/backend/product/dto/request/ProductAnalysisRequest.java b/src/main/java/com/webeye/backend/product/dto/request/ProductAnalysisRequest.java new file mode 100644 index 0000000..6f0a19e --- /dev/null +++ b/src/main/java/com/webeye/backend/product/dto/request/ProductAnalysisRequest.java @@ -0,0 +1,19 @@ +package com.webeye.backend.product.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import lombok.Builder; + +@Schema(description = "제품 분석") +@Builder +public record ProductAnalysisRequest( + @NotEmpty(message = "제품 ID는 비어있을 수 없습니다.") + String productId, + + @Schema(description = "상품 상세 정보 HTML") + @NotEmpty(message = "상품 상세 정보의 HTML은 비어있을 수 없습니다.") + @Pattern(regexp = ".*.*", message = "HTML에는 최소한 하나의 이미지 태그가 포함되어야 합니다.") + String html +) { +} \ No newline at end of file diff --git a/src/main/java/com/webeye/backend/product/dto/response/ProductResponse.java b/src/main/java/com/webeye/backend/product/dto/response/ProductResponse.java new file mode 100644 index 0000000..bf8596a --- /dev/null +++ b/src/main/java/com/webeye/backend/product/dto/response/ProductResponse.java @@ -0,0 +1,18 @@ +package com.webeye.backend.product.dto.response; + +import com.webeye.backend.allergy.type.AllergyType; +import com.webeye.backend.nutrition.dto.response.NutrientResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Schema(description = "제품 분석 응답 (SODIUM: 나트륨, CARBOHYDRATE: 탄수화물, SUGARS: 당류, FAT: 지방, TRANS_FAT: 트랜스지방, SATURATED_FAT: 포화지방, CHOLESTEROL: 콜레스테롤, PROTEIN: 단백질, CALCIUM: 칼슘, PHOSPHORUS: 인, NIACIN: 나이아신, VITAMIN_B: 비타민 B, VITAMIN_E: 비타민 E)") +@Builder +public record ProductResponse ( + @Schema(description = "포함된 알레르기 유발 성분") + List allergyTypes, + + NutrientResponse nutrientResponse +) { +} diff --git a/src/main/java/com/webeye/backend/product/persistent/ProductAllergyRepository.java b/src/main/java/com/webeye/backend/product/persistent/ProductAllergyRepository.java new file mode 100644 index 0000000..76a1b4f --- /dev/null +++ b/src/main/java/com/webeye/backend/product/persistent/ProductAllergyRepository.java @@ -0,0 +1,10 @@ +package com.webeye.backend.product.persistent; + +import com.webeye.backend.product.domain.ProductAllergy; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ProductAllergyRepository extends JpaRepository { +} + diff --git a/src/main/java/com/webeye/backend/product/persistent/ProductHealthFoodRepository.java b/src/main/java/com/webeye/backend/product/persistent/ProductHealthFoodRepository.java new file mode 100644 index 0000000..6c22ca7 --- /dev/null +++ b/src/main/java/com/webeye/backend/product/persistent/ProductHealthFoodRepository.java @@ -0,0 +1,7 @@ +package com.webeye.backend.product.persistent; + +import com.webeye.backend.product.domain.ProductHealthfood; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductHealthFoodRepository extends JpaRepository { +} diff --git a/src/main/java/com/webeye/backend/product/persistent/ProductNutrientRepository.java b/src/main/java/com/webeye/backend/product/persistent/ProductNutrientRepository.java new file mode 100644 index 0000000..026fab2 --- /dev/null +++ b/src/main/java/com/webeye/backend/product/persistent/ProductNutrientRepository.java @@ -0,0 +1,9 @@ +package com.webeye.backend.product.persistent; + +import com.webeye.backend.product.domain.ProductNutrient; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ProductNutrientRepository extends JpaRepository { +} diff --git a/src/main/java/com/webeye/backend/product/persistent/ProductRepository.java b/src/main/java/com/webeye/backend/product/persistent/ProductRepository.java new file mode 100644 index 0000000..bdf3412 --- /dev/null +++ b/src/main/java/com/webeye/backend/product/persistent/ProductRepository.java @@ -0,0 +1,23 @@ +package com.webeye.backend.product.persistent; + +import com.webeye.backend.product.domain.Product; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ProductRepository extends JpaRepository { + boolean existsById(String id); + + @Query("SELECT p FROM Product p LEFT JOIN FETCH p.review WHERE p.id = :id") + Optional findByIdWithReview(@Param("id") String id); + + @Query("SELECT p FROM Product p LEFT JOIN FETCH p.cosmeticIngredients WHERE p.id = :id") + Optional findByIdWithCosmeticIngredients(@Param("id") String id); + + @Query("SELECT p FROM Product p LEFT JOIN FETCH p.healthFoods ph LEFT JOIN FETCH ph.healthFood WHERE p.id = :id") + Optional findByIdWithHealthFoods(@Param("id") String productId); +} diff --git a/src/main/java/com/webeye/backend/product/presentation/ProductController.java b/src/main/java/com/webeye/backend/product/presentation/ProductController.java new file mode 100644 index 0000000..524613a --- /dev/null +++ b/src/main/java/com/webeye/backend/product/presentation/ProductController.java @@ -0,0 +1,27 @@ +package com.webeye.backend.product.presentation; + +import com.webeye.backend.global.dto.response.SuccessResponse; +import com.webeye.backend.product.dto.request.FoodProductAnalysisRequest; +import com.webeye.backend.product.application.ProductService; +import com.webeye.backend.product.dto.response.ProductResponse; +import com.webeye.backend.product.presentation.swagger.ProductSwagger; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import static com.webeye.backend.global.dto.response.type.SuccessCode.FOOD_PRODUCT_ANALYSIS_SUCCESS; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/v1/products") +public class ProductController implements ProductSwagger { + private final ProductService productService; + + @Override + @ResponseStatus(HttpStatus.OK) + @PostMapping(value = "/foods") + public SuccessResponse foodAnalysis(@Valid @RequestBody FoodProductAnalysisRequest request) { + return SuccessResponse.of(FOOD_PRODUCT_ANALYSIS_SUCCESS, productService.analyzeFoodProduct(request)); + } +} diff --git a/src/main/java/com/webeye/backend/product/presentation/swagger/ProductSwagger.java b/src/main/java/com/webeye/backend/product/presentation/swagger/ProductSwagger.java new file mode 100644 index 0000000..714d7f9 --- /dev/null +++ b/src/main/java/com/webeye/backend/product/presentation/swagger/ProductSwagger.java @@ -0,0 +1,27 @@ +package com.webeye.backend.product.presentation.swagger; + +import com.webeye.backend.global.dto.response.SuccessResponse; +import com.webeye.backend.product.dto.request.FoodProductAnalysisRequest; +import com.webeye.backend.product.dto.response.ProductResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "[제품]", description = "제품 관련 API") +public interface ProductSwagger { + @Operation( + summary = "음식 제품 분석", + description = "음식 제품을 분석합니다. 질환, 알레르기를 입력받아 이를 유발하는 성분과 영양소 권장량을 초과하는 성분이 포함되어 있는지를 반환합니다." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "음식 제품이 성공적으로 분석되었습니다." + ) + }) + SuccessResponse foodAnalysis( + @RequestBody FoodProductAnalysisRequest request + ); +} diff --git a/src/main/java/com/webeye/backend/productdetail/application/ProductDetailService.java b/src/main/java/com/webeye/backend/productdetail/application/ProductDetailService.java new file mode 100644 index 0000000..f218195 --- /dev/null +++ b/src/main/java/com/webeye/backend/productdetail/application/ProductDetailService.java @@ -0,0 +1,87 @@ +package com.webeye.backend.productdetail.application; + +import com.webeye.backend.imageanalysis.infrastructure.ImageUrlExtractor; +import com.webeye.backend.imageanalysis.infrastructure.OpenAiClient; +import com.webeye.backend.product.domain.Product; +import com.webeye.backend.productdetail.dto.request.ProductDetailAnalysisRequest; +import com.webeye.backend.productdetail.dto.response.DetailExplanationResponse; +import com.webeye.backend.product.persistent.ProductRepository; +import com.webeye.backend.productdetail.domain.ProductDetail; +import com.webeye.backend.productdetail.domain.type.OutlineType; +import com.webeye.backend.productdetail.dto.response.AllDetailExplanationResponse; +import com.webeye.backend.productdetail.persistent.ProductDetailRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ProductDetailService { + private final OpenAiClient openAiClient; + + private final ProductDetailRepository productDetailRepository; + private final ProductRepository productRepository; + + @Transactional + public DetailExplanationResponse analyzeProductDetail(OutlineType outline, ProductDetailAnalysisRequest request) { + log.info("[ProductDetailService] 📌 requested product ID: {}", request.productId()); + Optional productDetailOpt = productDetailRepository.findByProductIdAndOutline(request.productId(), outline); + if (productDetailOpt.isEmpty()) { + AllDetailExplanationResponse details = openAiClient.explainProductAllDetail(ImageUrlExtractor.extractImageUrlFromHtml(request.html())); + log.info("product detail response:\n{}", details.toString()); + + saveProductDetails(request.productId(), details); + return new DetailExplanationResponse(getContentByOutline(details, outline)); + } + return new DetailExplanationResponse(productDetailOpt.get().getContent()); + } + + @Transactional + public void saveProductDetails(String id, AllDetailExplanationResponse details) { + Product product = findOrCreateProduct(id); + + List productDetails = List.of( + createProductDetail(product, OutlineType.MAIN, details.main()), + createProductDetail(product, OutlineType.USAGE, details.usage()), + createProductDetail(product, OutlineType.WARNING, details.warning()), + createProductDetail(product, OutlineType.SPECS, details.specs()) + ); + product.addProductDetails(productDetails); + productDetailRepository.saveAll(productDetails); + } + + private ProductDetail createProductDetail(Product product, OutlineType outline, String content) { + if (content == null || content.trim().isEmpty()) { + log.warn("[ProductDetailService] 빈 content가 감지되었습니다. outline: {}, productId: {}", outline, product.getId()); + content = "해당 항목에 대한 정보는 기재되어 있지 않습니다."; + } + return ProductDetail.builder() + .product(product) + .outline(outline) + .content(content) + .build(); + } + + @Transactional + public Product findOrCreateProduct(String productId) { + return productRepository.findById(productId) + .orElseGet(() -> productRepository.save( + Product.builder() + .id(productId) + .build())); + } + + private String getContentByOutline(AllDetailExplanationResponse details, OutlineType outline) { + return switch (outline) { + case MAIN -> details.main(); + case USAGE -> details.usage(); + case WARNING -> details.warning(); + case SPECS -> details.specs(); + }; + } +} diff --git a/src/main/java/com/webeye/backend/productdetail/domain/ProductDetail.java b/src/main/java/com/webeye/backend/productdetail/domain/ProductDetail.java new file mode 100644 index 0000000..4330f36 --- /dev/null +++ b/src/main/java/com/webeye/backend/productdetail/domain/ProductDetail.java @@ -0,0 +1,48 @@ +package com.webeye.backend.productdetail.domain; + +import com.webeye.backend.global.domain.BaseEntity; +import com.webeye.backend.product.domain.Product; +import com.webeye.backend.productdetail.domain.type.OutlineType; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "product_detail", + uniqueConstraints = { + @UniqueConstraint(columnNames = {"product_id", "outline"}) + } +) +public class ProductDetail extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id") + private Product product; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + private OutlineType outline; + + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Builder + public ProductDetail(Product product, OutlineType outline, String content) { + this.product = product; + this.outline = outline; + this.content = content; + } + + public void associateWithProduct(Product product) { + this.product = product; + product.getDetails().add(this); + } +} diff --git a/src/main/java/com/webeye/backend/productdetail/domain/type/OutlineType.java b/src/main/java/com/webeye/backend/productdetail/domain/type/OutlineType.java new file mode 100644 index 0000000..e167d0b --- /dev/null +++ b/src/main/java/com/webeye/backend/productdetail/domain/type/OutlineType.java @@ -0,0 +1,18 @@ +package com.webeye.backend.productdetail.domain.type; + +import lombok.Getter; + +@Getter +public enum OutlineType { + MAIN("Analyze the product description image I send and provide information about the main ingredients, materials, functions, and components of the product. If it is a food item, nutritional information and Manufacturer and Distributor(Business Name and Address) must be included without exception. (e.g., ingredients/nutritional content, country of origin, material, functionality, components, manufacturer)"), + USAGE("Analyze the product description image I send and provide information about how to use (consume/assemble/install/utilize) the product and the intended user. (e.g., consumption method, usage steps, recommended users, age group, installation method)"), + WARNING("Analyze the product description image I send and provide information about storage methods, expiration date, safety precautions, allergies, cleaning, etc. If it is a food item, allergy information is required. (e.g., storage method, avoid direct sunlight, expiration date, washing, prohibitions)"), + SPECS("Analyze the product description image I send and provide information about the product’s color, size, weight, capacity, compatibility, and purchasing options. (e.g., size, weight, capacity, size options, color, option configuration, coverage)") + ; + + private final String prompt; + + OutlineType(String prompt) { + this.prompt = prompt; + } +} diff --git a/src/main/java/com/webeye/backend/productdetail/dto/request/ProductDetailAnalysisRequest.java b/src/main/java/com/webeye/backend/productdetail/dto/request/ProductDetailAnalysisRequest.java new file mode 100644 index 0000000..a929793 --- /dev/null +++ b/src/main/java/com/webeye/backend/productdetail/dto/request/ProductDetailAnalysisRequest.java @@ -0,0 +1,21 @@ +package com.webeye.backend.productdetail.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import lombok.Builder; + +@Schema(description = "상품 설명 요청") +@Builder +public record ProductDetailAnalysisRequest( + @Schema(description = "상품 ID") + @NotBlank(message = "상품 ID는 비어있을 수 없습니다.") + String productId, + + @Schema(description = "상품 상세 정보 HTML") + @NotEmpty(message = "상품 상세 정보의 HTML은 비어있을 수 없습니다.") + @Pattern(regexp = ".*.*", message = "HTML에는 최소한 하나의 이미지 태그가 포함되어야 합니다.") + String html +){ +} diff --git a/src/main/java/com/webeye/backend/productdetail/dto/response/AllDetailExplanationResponse.java b/src/main/java/com/webeye/backend/productdetail/dto/response/AllDetailExplanationResponse.java new file mode 100644 index 0000000..642c615 --- /dev/null +++ b/src/main/java/com/webeye/backend/productdetail/dto/response/AllDetailExplanationResponse.java @@ -0,0 +1,12 @@ +package com.webeye.backend.productdetail.dto.response; + +import lombok.Builder; + +@Builder +public record AllDetailExplanationResponse( + String main, + String usage, + String warning, + String specs +) { +} diff --git a/src/main/java/com/webeye/backend/productdetail/dto/response/DetailExplanationResponse.java b/src/main/java/com/webeye/backend/productdetail/dto/response/DetailExplanationResponse.java new file mode 100644 index 0000000..6e5ef88 --- /dev/null +++ b/src/main/java/com/webeye/backend/productdetail/dto/response/DetailExplanationResponse.java @@ -0,0 +1,9 @@ +package com.webeye.backend.productdetail.dto.response; + +import lombok.Builder; + +@Builder +public record DetailExplanationResponse ( + String detail +) { +} diff --git a/src/main/java/com/webeye/backend/productdetail/persistent/ProductDetailRepository.java b/src/main/java/com/webeye/backend/productdetail/persistent/ProductDetailRepository.java new file mode 100644 index 0000000..525655d --- /dev/null +++ b/src/main/java/com/webeye/backend/productdetail/persistent/ProductDetailRepository.java @@ -0,0 +1,13 @@ +package com.webeye.backend.productdetail.persistent; + +import com.webeye.backend.productdetail.domain.ProductDetail; +import com.webeye.backend.productdetail.domain.type.OutlineType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface ProductDetailRepository extends JpaRepository { + Optional findByProductIdAndOutline(String productId, OutlineType outlineType); +} diff --git a/src/main/java/com/webeye/backend/productdetail/presentation/ProductDetailController.java b/src/main/java/com/webeye/backend/productdetail/presentation/ProductDetailController.java new file mode 100644 index 0000000..211534f --- /dev/null +++ b/src/main/java/com/webeye/backend/productdetail/presentation/ProductDetailController.java @@ -0,0 +1,30 @@ +package com.webeye.backend.productdetail.presentation; + +import com.webeye.backend.global.dto.response.SuccessResponse; +import com.webeye.backend.productdetail.dto.request.ProductDetailAnalysisRequest; +import com.webeye.backend.productdetail.dto.response.DetailExplanationResponse; +import com.webeye.backend.productdetail.application.ProductDetailService; +import com.webeye.backend.productdetail.domain.type.OutlineType; +import com.webeye.backend.productdetail.presentation.swagger.ProductDetailSwagger; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import static com.webeye.backend.global.dto.response.type.SuccessCode.PRODUCT_DETAIL_EXPLANATION_ANALYSIS_SUCCESS; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/v1/product-detail") +public class ProductDetailController implements ProductDetailSwagger { + private final ProductDetailService productDetailService; + + @Override + @ResponseStatus(HttpStatus.OK) + @PostMapping(value = "/{outline}") + public SuccessResponse productDetailAnalysis( + @PathVariable OutlineType outline, @Valid @RequestBody ProductDetailAnalysisRequest request) { + return SuccessResponse.of(PRODUCT_DETAIL_EXPLANATION_ANALYSIS_SUCCESS, + productDetailService.analyzeProductDetail(outline, request)); + } +} diff --git a/src/main/java/com/webeye/backend/productdetail/presentation/swagger/ProductDetailSwagger.java b/src/main/java/com/webeye/backend/productdetail/presentation/swagger/ProductDetailSwagger.java new file mode 100644 index 0000000..e0cfd6a --- /dev/null +++ b/src/main/java/com/webeye/backend/productdetail/presentation/swagger/ProductDetailSwagger.java @@ -0,0 +1,33 @@ +package com.webeye.backend.productdetail.presentation.swagger; + +import com.webeye.backend.global.dto.response.SuccessResponse; +import com.webeye.backend.productdetail.dto.request.ProductDetailAnalysisRequest; +import com.webeye.backend.productdetail.dto.response.DetailExplanationResponse; +import com.webeye.backend.productdetail.domain.type.OutlineType; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "[제품 상세 설명]", description = "제품 상세 설명 관련 API") +public interface ProductDetailSwagger { + @Operation( + summary = "제품 주요 개요에 대한 상세 설명 추출", + description = "제품 ID와 상세 설명 이미지가 포함된 HTML, 개요를 입력받아 주요 요소에 대한 상세 설명을 추출합니다. " + + "MAIN: 주요정보, USAGE: 사용정보, WARNING: 주의 및 보관, SPECS: 규격 및 옵션" + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "제품 주요 요소에 대한 상세 설명이 추출되었습니다." + ) + }) + SuccessResponse productDetailAnalysis( + @Parameter(in = ParameterIn.PATH, description = "상품 개요", required = true) + OutlineType outline, + @RequestBody ProductDetailAnalysisRequest request + ); +} diff --git a/src/main/java/com/webeye/backend/rawmaterial/application/RawMaterialService.java b/src/main/java/com/webeye/backend/rawmaterial/application/RawMaterialService.java new file mode 100644 index 0000000..e2273bb --- /dev/null +++ b/src/main/java/com/webeye/backend/rawmaterial/application/RawMaterialService.java @@ -0,0 +1,73 @@ +package com.webeye.backend.rawmaterial.application; + +import com.webeye.backend.imageanalysis.infrastructure.OpenAiClient; +import com.webeye.backend.nutrition.domain.Nutrient; +import com.webeye.backend.nutrition.domain.type.NutrientType; +import com.webeye.backend.nutrition.persistent.NutrientRepository; +import com.webeye.backend.product.domain.Product; +import com.webeye.backend.product.domain.ProductNutrient; +import com.webeye.backend.product.dto.request.FoodProductAnalysisRequest; +import com.webeye.backend.rawmaterial.domain.RawMaterial; +import com.webeye.backend.rawmaterial.persistent.RawMaterialRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.EnumMap; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RawMaterialService { + private final OpenAiClient openAiClient; + + private final RawMaterialRepository rawMaterialRepository; + private final NutrientRepository nutrientRepository; + + @Transactional + public void saveRawMaterialNutrition(Product product, FoodProductAnalysisRequest request) { + product.setNutrientReferenceAmount(100); + String foodName = openAiClient.explainRawMaterial(request).name(); + Optional rawMaterialOpt = rawMaterialRepository.findFirstByNameContaining(foodName); + rawMaterialOpt.ifPresentOrElse(rawMaterial -> { + Map nutrientMap = convertToNutrientMap(rawMaterial); + + nutrientMap.forEach((type, amount) -> { + if (amount == null) return; + Nutrient nutrient = findByType(type); + product.addNutrient( + ProductNutrient.builder() + .product(product) + .nutrient(nutrient) + .amount(amount) + .build() + ); + }); + }, () -> log.warn("일치하는 원재료 데이터가 존재하지 않습니다.")); + } + + private Map convertToNutrientMap(RawMaterial rawMaterial) { + Map map = new EnumMap<>(NutrientType.class); + map.put(NutrientType.SODIUM, rawMaterial.getSodium()); + map.put(NutrientType.CARBOHYDRATE, rawMaterial.getCarbohydrate()); + map.put(NutrientType.SUGARS, rawMaterial.getSugars()); + map.put(NutrientType.FAT, rawMaterial.getFat()); + map.put(NutrientType.TRANS_FAT, rawMaterial.getTransFat()); + map.put(NutrientType.SATURATED_FAT, rawMaterial.getSaturatedFat()); + map.put(NutrientType.CHOLESTEROL, rawMaterial.getCholesterol()); + map.put(NutrientType.PROTEIN, rawMaterial.getProtein()); + map.put(NutrientType.CALCIUM, rawMaterial.getCalcium()); + map.put(NutrientType.PHOSPHORUS, rawMaterial.getPhosphorus()); + map.put(NutrientType.NIACIN, rawMaterial.getNiacin()); + return map; + } + + private Nutrient findByType(NutrientType type) { + return nutrientRepository.findByType(type) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 영양소 타입: " + type)); + } +} diff --git a/src/main/java/com/webeye/backend/rawmaterial/domain/RawMaterial.java b/src/main/java/com/webeye/backend/rawmaterial/domain/RawMaterial.java new file mode 100644 index 0000000..13c525b --- /dev/null +++ b/src/main/java/com/webeye/backend/rawmaterial/domain/RawMaterial.java @@ -0,0 +1,72 @@ +package com.webeye.backend.rawmaterial.domain; + +import com.webeye.backend.global.domain.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RawMaterial extends BaseEntity { + + @Id + @Column(name = "raw_material_id", nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private String name; + + private Double protein; // 단백질 + + private Double fat; // 지방 + + private Double carbohydrate; // 탄수화물 + + private Double sugars; // 당류 + + private Double calcium; // 칼슘 + + private Double phosphorus; // 인 + + private Double sodium; // 나트륨 + + private Double niacin; // 나이아신 + + private Double cholesterol; // 콜레스테롤 + + private Double saturatedFat; // 포화지방 + + private Double transFat; // 트랜스지방 + + @Builder + private RawMaterial( + String name, + Double protein, + Double fat, + Double carbohydrate, + Double sugars, + Double calcium, + Double phosphorus, + Double sodium, + Double niacin, + Double cholesterol, + Double saturatedFat, + Double transFat + ) { + this.name = name; + this.protein = protein; + this.fat = fat; + this.carbohydrate = carbohydrate; + this.sugars = sugars; + this.calcium = calcium; + this.phosphorus = phosphorus; + this.sodium = sodium; + this.niacin = niacin; + this.cholesterol = cholesterol; + this.saturatedFat = saturatedFat; + this.transFat = transFat; + } +} \ No newline at end of file diff --git a/src/main/java/com/webeye/backend/rawmaterial/dto/response/RawMaterialAiResponse.java b/src/main/java/com/webeye/backend/rawmaterial/dto/response/RawMaterialAiResponse.java new file mode 100644 index 0000000..f238ee9 --- /dev/null +++ b/src/main/java/com/webeye/backend/rawmaterial/dto/response/RawMaterialAiResponse.java @@ -0,0 +1,12 @@ +package com.webeye.backend.rawmaterial.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Schema(description = "원재료 식품 AI 응답") +@Builder +public record RawMaterialAiResponse( + @Schema(description = "원재료 식품 이름") + String name +) { +} diff --git a/src/main/java/com/webeye/backend/rawmaterial/persistent/RawMaterialRepository.java b/src/main/java/com/webeye/backend/rawmaterial/persistent/RawMaterialRepository.java new file mode 100644 index 0000000..1be7f16 --- /dev/null +++ b/src/main/java/com/webeye/backend/rawmaterial/persistent/RawMaterialRepository.java @@ -0,0 +1,12 @@ +package com.webeye.backend.rawmaterial.persistent; + +import com.webeye.backend.rawmaterial.domain.RawMaterial; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface RawMaterialRepository extends JpaRepository { + Optional findFirstByNameContaining(String name); +} diff --git a/src/main/java/com/webeye/backend/review/application/ReviewService.java b/src/main/java/com/webeye/backend/review/application/ReviewService.java new file mode 100644 index 0000000..c959f6b --- /dev/null +++ b/src/main/java/com/webeye/backend/review/application/ReviewService.java @@ -0,0 +1,68 @@ +package com.webeye.backend.review.application; + +import com.webeye.backend.product.domain.Product; +import com.webeye.backend.product.persistent.ProductRepository; +import com.webeye.backend.review.domain.Review; +import com.webeye.backend.review.dto.request.ReviewSummaryRequest; +import com.webeye.backend.review.dto.response.ReviewSummaryResponse; +import com.webeye.backend.review.infrastructure.clovaX.ClovaXClientService; +import com.webeye.backend.review.infrastructure.mapper.ReviewMapper; +import com.webeye.backend.review.infrastructure.persistence.ReviewRepository; +import com.webeye.backend.review.infrastructure.util.ReviewCalculator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class ReviewService { + + private final ClovaXClientService clovaXClientService; + private final ReviewRepository reviewRepository; + private final ProductRepository productRepository; + + @Transactional + public ReviewSummaryResponse summarizeReview(ReviewSummaryRequest request) { + Product product = findOrCreateProduct(request.productId()); + + // DB에 존재할 경우, DB에서 반환 + Review existingReview = product.getReview(); + if (existingReview != null) { + return ReviewMapper.toResponse(existingReview, request.reviewRating().totalCount()); + } + Map ratingMap = ReviewCalculator.convertToRatingMap(request.reviewRating().ratings()); + + // 리뷰 내 별점만 존재하고 리뷰 내용은 존재하지 않을 경우 + if (request.reviews() == null || request.reviews().isEmpty()) { + double average = ReviewCalculator.calculateAverageRating(ratingMap, request.reviewRating().totalCount()); + ReviewSummaryResponse nullResponse = ReviewMapper.toNullResponse(request.reviewRating().totalCount(), average); + + Review review = ReviewMapper.toEntity(nullResponse, product); + reviewRepository.save(review); + + return ReviewMapper.toResponse(review, request.reviewRating().totalCount()); + } + + ReviewSummaryResponse response = clovaXClientService.summarizeReviewText( + String.join(". ", request.reviews()), + ratingMap, + request.reviewRating().totalCount() + ); + Review review = ReviewMapper.toEntity(response, product); + + reviewRepository.save(review); + + return response; + } + + @Transactional + public Product findOrCreateProduct(String productId) { + return productRepository.findByIdWithReview(productId) + .orElseGet(() -> productRepository.save( + Product.builder() + .id(productId) + .build())); + } +} diff --git a/src/main/java/com/webeye/backend/review/domain/Review.java b/src/main/java/com/webeye/backend/review/domain/Review.java new file mode 100644 index 0000000..b8cd1f9 --- /dev/null +++ b/src/main/java/com/webeye/backend/review/domain/Review.java @@ -0,0 +1,56 @@ +package com.webeye.backend.review.domain; + +import com.webeye.backend.global.domain.BaseEntity; +import com.webeye.backend.product.domain.Product; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Review extends BaseEntity { + + @Id + @Column(name = "review_id", nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Double averageRating; + + @Column(nullable = false) + private String positiveSummary; + + @Column(nullable = false) + private String negativeSummary; + + @Column(nullable = false) + private String keywords; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id", unique = true) + private Product product; + + @Builder + public Review( + Double averageRating, + String positiveSummary, + String negativeSummary, + String keywords + ) { + this.averageRating = averageRating; + this.positiveSummary = positiveSummary; + this.negativeSummary = negativeSummary; + this.keywords = keywords; + } + + public void associateWithProduct(Product product) { + this.product = product; + if (product.getReview() != this) { + product.associateWithReview(this); + } + } +} diff --git a/src/main/java/com/webeye/backend/review/dto/request/ReviewSummaryRequest.java b/src/main/java/com/webeye/backend/review/dto/request/ReviewSummaryRequest.java new file mode 100644 index 0000000..320d62f --- /dev/null +++ b/src/main/java/com/webeye/backend/review/dto/request/ReviewSummaryRequest.java @@ -0,0 +1,29 @@ +package com.webeye.backend.review.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema(description = "리뷰 만족도") +public record ReviewSummaryRequest( + @Schema(description = "쿠팡 상품 ID", example = "85241789") + String productId, + + @Schema(description = "별점 통계") + ReviewRating reviewRating, + + @Schema(description = "쿠팡 리뷰 목록", example = "[\"맛있어요\", \"배송 느려요\", \"부드러워요\"]") + List reviews +) { + public record ReviewRating( + @Schema(description = "총 별점 수", example = "150") + int totalCount, + + @Schema(description = "별점 등급별 수", example = """ + [83, 11, 4, 1, 1] + """) + List ratings + ){} +} diff --git a/src/main/java/com/webeye/backend/review/dto/response/ReviewSummaryResponse.java b/src/main/java/com/webeye/backend/review/dto/response/ReviewSummaryResponse.java new file mode 100644 index 0000000..2ad1e8d --- /dev/null +++ b/src/main/java/com/webeye/backend/review/dto/response/ReviewSummaryResponse.java @@ -0,0 +1,26 @@ +package com.webeye.backend.review.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Builder +@Schema(description = "리뷰 요약") +public record ReviewSummaryResponse( + @Schema(description = "총 리뷰 수", example = "45078") + int totalCount, + + @Schema(description = "평균 별점", example = "4.85") + double averageRating, + + @Schema(description = "긍정 리뷰", example = "맛있다는 평가가 많습니다.") + List positiveReviews, + + @Schema(description = "부정 리뷰", example = "배송이 느리다는 평가가 많습니다.") + List negativeReviews, + + @Schema(description = "키워드", example = "맛있어요, 신선해요") + List keywords +) { +} \ No newline at end of file diff --git a/src/main/java/com/webeye/backend/review/infrastructure/clovaX/ClovaXClient.java b/src/main/java/com/webeye/backend/review/infrastructure/clovaX/ClovaXClient.java new file mode 100644 index 0000000..54b043d --- /dev/null +++ b/src/main/java/com/webeye/backend/review/infrastructure/clovaX/ClovaXClient.java @@ -0,0 +1,23 @@ +package com.webeye.backend.review.infrastructure.clovaX; + +import com.webeye.backend.global.config.OpenFeignConfig; +import com.webeye.backend.review.infrastructure.clovaX.dto.request.ClovaXRequest; +import com.webeye.backend.review.infrastructure.clovaX.dto.response.ClovaXResponse; +import jakarta.validation.Valid; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; + +@FeignClient( + name = "clovaXClient", + url = "${clova.url}", + configuration = OpenFeignConfig.class +) +public interface ClovaXClient { + + @PostMapping + ClovaXResponse createReviewSummary( + @RequestHeader("Authorization") String authorization, + @RequestHeader("X-NCP-CLOVASTUDIO-REQUEST-ID") String requestId, + @RequestBody @Valid ClovaXRequest request + ); +} diff --git a/src/main/java/com/webeye/backend/review/infrastructure/clovaX/ClovaXClientService.java b/src/main/java/com/webeye/backend/review/infrastructure/clovaX/ClovaXClientService.java new file mode 100644 index 0000000..dae42d3 --- /dev/null +++ b/src/main/java/com/webeye/backend/review/infrastructure/clovaX/ClovaXClientService.java @@ -0,0 +1,121 @@ +package com.webeye.backend.review.infrastructure.clovaX; + +import com.webeye.backend.review.infrastructure.util.ReviewCalculator; +import com.webeye.backend.review.dto.response.ReviewSummaryResponse; +import com.webeye.backend.review.infrastructure.clovaX.dto.request.ClovaXContent; +import com.webeye.backend.review.infrastructure.clovaX.dto.request.ClovaXMessage; +import com.webeye.backend.review.infrastructure.clovaX.dto.request.ClovaXRequest; +import com.webeye.backend.review.infrastructure.clovaX.dto.response.ClovaXResponse; +import com.webeye.backend.review.infrastructure.clovaX.model.ContentType; +import com.webeye.backend.review.infrastructure.clovaX.model.Role; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ClovaXClientService { + + private final ClovaXClient clovaXClient; + + @Value("${clova.secret-key}") + private String secretKey; + + @Value("${clova.request-id}") + private String requestId; + + public ReviewSummaryResponse summarizeReviewText(String reviewText, Map ratingMap, int totalCount) { + double averageRating = ReviewCalculator.calculateAverageRating(ratingMap, totalCount); + + ClovaXRequest request = buildReviewSummaryPrompt(reviewText); + + try { + ClovaXResponse clovaXResponse = clovaXClient.createReviewSummary("Bearer "+ secretKey, requestId, request); + + log.info("reviewText 길이: {}", reviewText.length()); + log.info("[Clova 요약 응답] content = {}", clovaXResponse.result().message().content()); + + return parseResponse(clovaXResponse.result().message().content(), totalCount, averageRating); + } catch (Exception e) { + log.error("Clova 요약 실패: {}", e.getMessage(), e); + throw new RuntimeException("Clova 요약 중 오류 발생", e); + } + } + + private ClovaXRequest buildReviewSummaryPrompt(String inputText) { + String prompt = """ + 너는 사용자의 리뷰 데이터를 분석하여 아래 3가지 항목으로 요약하는 AI야: + **번호 및 글머리 기호를 붙이지 말고**, 정확한 형식을 지켜줘. + '~며', '~고', '~하며' 같은 연결어도 절대 쓰지 마. 문장이 길어지지 않게 최대한 간결하게 써. + + 1. 긍정적인 내용 요약 (한 문장으로, 마침표로 구분, 서로 독립된 짧은 문장으로 작성) + 2. 부정적인 내용 요약 (한 문장으로, 마침표로 구분, 서로 독립된 짧은 문장으로 작성) + 3. 대표 키워드 3개 추출 (한 문장으로, 콤마로 구분) + + 결과는 반드시 다음 형식으로 반환해: + 긍정 리뷰: 긍정 리뷰 1, 긍정 리뷰 2, 긍정 리뷰 3 + 부정 리뷰: 부정 리뷰 1, 부정 리뷰 2, 부정 리뷰 3 + 키워드: 키워드 1, 키워드 2, 키워드 3 + + 예시: + 긍정 리뷰: 맛있다는 평가가 많습니다, 달콤하다는 평가가 많습니다 + 부정 리뷰: 배송이 느리다는 평가가 많습니다, 포장이 별로라는 평가가 많습니다 + 키워드: 맛있어요, 신선해요, 배송이 느려요 + + 주의사항: + 1. 반드시 **예시의 형식**과 동일하게 반환해. + """; + + return new ClovaXRequest(List.of( + new ClovaXMessage(Role.SYSTEM, List.of( + new ClovaXContent(ContentType.TEXT, prompt) + )), + new ClovaXMessage(Role.USER, List.of( + new ClovaXContent(ContentType.TEXT, inputText) + )) + )); + } + + private ReviewSummaryResponse parseResponse(String content, int totalCount, double averageRating) { + String[] lines = content.split("\n"); + + List positiveReviews = new ArrayList<>(); + List negativeReviews = new ArrayList<>(); + List keywords = new ArrayList<>(); + + for (String rawLine : lines) { + String line = rawLine.replaceAll("\\*\\*", "").trim(); + + if (line.startsWith("긍정 리뷰:")) { + String text = line.replace("긍정 리뷰:", "").trim(); + + positiveReviews = Arrays.stream(text.split("[.]")) + .map(String::trim) + .filter(s -> !s.isBlank()) + .limit(3) + .toList(); + } else if (line.startsWith("부정 리뷰:")) { + String text = line.replace("부정 리뷰:", "").trim(); + + negativeReviews = Arrays.stream(text.split("[.]")) + .map(String::trim) + .filter(s -> !s.isBlank()) + .limit(3) + .toList(); + } else if (line.startsWith("키워드:")) { + String[] tokens = line.replace("키워드:", "").split(","); + + keywords = Arrays.stream(tokens) + .map(String::trim) + .filter(s -> !s.isBlank()) + .limit(3) + .toList(); + } + } + return new ReviewSummaryResponse(totalCount, averageRating, positiveReviews, negativeReviews, keywords); + } +} diff --git a/src/main/java/com/webeye/backend/review/infrastructure/clovaX/dto/request/ClovaXContent.java b/src/main/java/com/webeye/backend/review/infrastructure/clovaX/dto/request/ClovaXContent.java new file mode 100644 index 0000000..1cee3b0 --- /dev/null +++ b/src/main/java/com/webeye/backend/review/infrastructure/clovaX/dto/request/ClovaXContent.java @@ -0,0 +1,9 @@ +package com.webeye.backend.review.infrastructure.clovaX.dto.request; + +import com.webeye.backend.review.infrastructure.clovaX.model.ContentType; + +public record ClovaXContent( + ContentType type, + String text +) { +} diff --git a/src/main/java/com/webeye/backend/review/infrastructure/clovaX/dto/request/ClovaXMessage.java b/src/main/java/com/webeye/backend/review/infrastructure/clovaX/dto/request/ClovaXMessage.java new file mode 100644 index 0000000..f29795c --- /dev/null +++ b/src/main/java/com/webeye/backend/review/infrastructure/clovaX/dto/request/ClovaXMessage.java @@ -0,0 +1,11 @@ +package com.webeye.backend.review.infrastructure.clovaX.dto.request; + +import com.webeye.backend.review.infrastructure.clovaX.model.Role; + +import java.util.List; + +public record ClovaXMessage( + Role role, + List content +) { +} diff --git a/src/main/java/com/webeye/backend/review/infrastructure/clovaX/dto/request/ClovaXRequest.java b/src/main/java/com/webeye/backend/review/infrastructure/clovaX/dto/request/ClovaXRequest.java new file mode 100644 index 0000000..692c4e7 --- /dev/null +++ b/src/main/java/com/webeye/backend/review/infrastructure/clovaX/dto/request/ClovaXRequest.java @@ -0,0 +1,8 @@ +package com.webeye.backend.review.infrastructure.clovaX.dto.request; + +import java.util.List; + +public record ClovaXRequest( + List messages +) { +} diff --git a/src/main/java/com/webeye/backend/review/infrastructure/clovaX/dto/response/ClovaXResponse.java b/src/main/java/com/webeye/backend/review/infrastructure/clovaX/dto/response/ClovaXResponse.java new file mode 100644 index 0000000..bd0e7b1 --- /dev/null +++ b/src/main/java/com/webeye/backend/review/infrastructure/clovaX/dto/response/ClovaXResponse.java @@ -0,0 +1,20 @@ +package com.webeye.backend.review.infrastructure.clovaX.dto.response; + +public record ClovaXResponse( + Status status, + Result result +) { + public record Status( + String code, + String message + ) {} + + public record Result( + Message message + ) { + public record Message( + String role, + String content + ) {} + } +} diff --git a/src/main/java/com/webeye/backend/review/infrastructure/clovaX/model/ContentType.java b/src/main/java/com/webeye/backend/review/infrastructure/clovaX/model/ContentType.java new file mode 100644 index 0000000..3a407b0 --- /dev/null +++ b/src/main/java/com/webeye/backend/review/infrastructure/clovaX/model/ContentType.java @@ -0,0 +1,21 @@ +package com.webeye.backend.review.infrastructure.clovaX.model; + +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.Getter; + +@Getter +public enum ContentType { + TEXT("text"), + IMAGE_URL("image_url"); + + private final String type; + + ContentType(String type) { + this.type = type; + } + + @JsonValue + public String getType() { + return type; + } +} diff --git a/src/main/java/com/webeye/backend/review/infrastructure/clovaX/model/Role.java b/src/main/java/com/webeye/backend/review/infrastructure/clovaX/model/Role.java new file mode 100644 index 0000000..ce676bc --- /dev/null +++ b/src/main/java/com/webeye/backend/review/infrastructure/clovaX/model/Role.java @@ -0,0 +1,22 @@ +package com.webeye.backend.review.infrastructure.clovaX.model; + +import com.fasterxml.jackson.annotation.JsonValue; +import lombok.Getter; + +@Getter +public enum Role { + SYSTEM("system"), + USER("user"), + ASSISTANT("assistant"); + + private final String role; + + Role(String role) { + this.role = role; + } + + @JsonValue + public String getRole() { + return role; + } +} diff --git a/src/main/java/com/webeye/backend/review/infrastructure/mapper/ReviewMapper.java b/src/main/java/com/webeye/backend/review/infrastructure/mapper/ReviewMapper.java new file mode 100644 index 0000000..81f5872 --- /dev/null +++ b/src/main/java/com/webeye/backend/review/infrastructure/mapper/ReviewMapper.java @@ -0,0 +1,43 @@ +package com.webeye.backend.review.infrastructure.mapper; + +import com.webeye.backend.product.domain.Product; +import com.webeye.backend.review.domain.Review; +import com.webeye.backend.review.dto.response.ReviewSummaryResponse; + +import java.util.Arrays; +import java.util.List; + +public class ReviewMapper { + + public static Review toEntity(ReviewSummaryResponse response, Product product) { + Review review = Review.builder() + .averageRating(response.averageRating()) + .positiveSummary(String.join("||", response.positiveReviews())) + .negativeSummary(String.join("||", response.negativeReviews())) + .keywords(String.join(",", response.keywords())) + .build(); + review.associateWithProduct(product); + + return review; + } + + public static ReviewSummaryResponse toResponse(Review review, int totalCount) { + return new ReviewSummaryResponse( + totalCount, + review.getAverageRating(), + Arrays.asList(review.getPositiveSummary().split("\\|\\|")), + Arrays.asList(review.getNegativeSummary().split("\\|\\|")), + Arrays.asList(review.getKeywords().split(",")) + ); + } + + public static ReviewSummaryResponse toNullResponse(int totalCount, double averageRating) { + return new ReviewSummaryResponse( + totalCount, + averageRating, + List.of("리뷰가 존재하지 않습니다."), + List.of("리뷰가 존재하지 않습니다."), + List.of("리뷰가 존재하지 않습니다.") + ); + } +} diff --git a/src/main/java/com/webeye/backend/review/infrastructure/persistence/ReviewRepository.java b/src/main/java/com/webeye/backend/review/infrastructure/persistence/ReviewRepository.java new file mode 100644 index 0000000..f54fd1c --- /dev/null +++ b/src/main/java/com/webeye/backend/review/infrastructure/persistence/ReviewRepository.java @@ -0,0 +1,7 @@ +package com.webeye.backend.review.infrastructure.persistence; + +import com.webeye.backend.review.domain.Review; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReviewRepository extends JpaRepository { +} diff --git a/src/main/java/com/webeye/backend/review/infrastructure/util/ReviewCalculator.java b/src/main/java/com/webeye/backend/review/infrastructure/util/ReviewCalculator.java new file mode 100644 index 0000000..07cb052 --- /dev/null +++ b/src/main/java/com/webeye/backend/review/infrastructure/util/ReviewCalculator.java @@ -0,0 +1,44 @@ +package com.webeye.backend.review.infrastructure.util; + +import lombok.extern.slf4j.Slf4j; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +public class ReviewCalculator { + + public static final List RATING_LABELS = List.of("최고", "좋음", "보통", "별로", "나쁨"); + + public static final Map RATING_POINTS = Map.of( + "최고", 5, + "좋음", 4, + "보통", 3, + "별로", 2, + "나쁨", 1 + ); + + public static double calculateAverageRating(Map ratings, int totalCount) { + int totalScore = ratings.entrySet().stream() + .mapToInt(entry -> { + Integer rating = RATING_POINTS.get(entry.getKey()); + Integer score = entry.getValue(); + return rating != null ? rating * score : 0; + }) + .sum(); + + log.info("[평균 별점 계산] ratings: {}", ratings); + log.info("[평균 별점 계산] totalCount: {}", totalCount); + + return totalCount == 0 ? 0.0 : Math.round((totalScore / (double) totalCount) * 100.0) / 100.0; + } + + public static Map convertToRatingMap(List ratingsList) { + Map ratingMap = new LinkedHashMap<>(); + for (int i = 0; i < RATING_LABELS.size(); i++) { + ratingMap.put(RATING_LABELS.get(i), i < ratingsList.size() ? ratingsList.get(i) : 0); + } + return ratingMap; + } +} diff --git a/src/main/java/com/webeye/backend/review/presentation/ReviewController.java b/src/main/java/com/webeye/backend/review/presentation/ReviewController.java new file mode 100644 index 0000000..25ee0ef --- /dev/null +++ b/src/main/java/com/webeye/backend/review/presentation/ReviewController.java @@ -0,0 +1,30 @@ +package com.webeye.backend.review.presentation; + +import com.webeye.backend.global.dto.response.SuccessResponse; +import com.webeye.backend.review.application.ReviewService; +import com.webeye.backend.review.dto.request.ReviewSummaryRequest; +import com.webeye.backend.review.dto.response.ReviewSummaryResponse; +import com.webeye.backend.review.presentation.swagger.ReviewSwagger; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import static com.webeye.backend.global.dto.response.type.SuccessCode.REVIEW_SUMMARY_SUCCESS; + +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/v1/review") +public class ReviewController implements ReviewSwagger { + + private final ReviewService reviewService; + + @Override + @PostMapping("/summary") + @ResponseStatus(HttpStatus.OK) + public SuccessResponse summarizeReview(@RequestBody @Valid ReviewSummaryRequest request) { + return SuccessResponse.of(REVIEW_SUMMARY_SUCCESS, reviewService.summarizeReview(request)); + } +} diff --git a/src/main/java/com/webeye/backend/review/presentation/swagger/ReviewSwagger.java b/src/main/java/com/webeye/backend/review/presentation/swagger/ReviewSwagger.java new file mode 100644 index 0000000..6784322 --- /dev/null +++ b/src/main/java/com/webeye/backend/review/presentation/swagger/ReviewSwagger.java @@ -0,0 +1,26 @@ +package com.webeye.backend.review.presentation.swagger; + +import com.webeye.backend.global.dto.response.SuccessResponse; +import com.webeye.backend.review.dto.request.ReviewSummaryRequest; +import com.webeye.backend.review.dto.response.ReviewSummaryResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "[리뷰 요약]", description = "리뷰 요약 및 분석 관련 API") +public interface ReviewSwagger { + @Operation( + summary = "제품의 리뷰 요약 및 분석", + description = "해당 제품에 대한 전체적인 리뷰를 요약 및 분석합니다." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "리뷰 요약 및 분석이 성공적으로 수행되었습니다." + ) + }) + SuccessResponse summarizeReview(@RequestBody @Valid ReviewSummaryRequest request); +} diff --git a/src/test/java/com/webeye/backend/BackendApplicationTests.java b/src/test/java/com/webeye/backend/BackendApplicationTests.java new file mode 100644 index 0000000..2cf5d31 --- /dev/null +++ b/src/test/java/com/webeye/backend/BackendApplicationTests.java @@ -0,0 +1,13 @@ +package com.webeye.backend; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class BackendApplicationTests { + + @Test + void contextLoads() { + } + +}