diff --git a/build.gradle b/build.gradle index a979070..cac4521 100644 --- a/build.gradle +++ b/build.gradle @@ -52,6 +52,9 @@ dependencies { implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' // S3 설정 implementation 'com.amazonaws:aws-java-sdk-s3:1.12.649' + + // HTTP 클라이언트 (파이썬 서버 통신용) + implementation 'org.springframework.boot:spring-boot-starter-webflux' } tasks.named('test') { diff --git a/src/main/java/com/DecodEat/domain/products/client/PythonAnalysisClient.java b/src/main/java/com/DecodEat/domain/products/client/PythonAnalysisClient.java new file mode 100644 index 0000000..1fd398d --- /dev/null +++ b/src/main/java/com/DecodEat/domain/products/client/PythonAnalysisClient.java @@ -0,0 +1,36 @@ +package com.DecodEat.domain.products.client; + +import com.DecodEat.domain.products.dto.request.AnalysisRequestDto; +import com.DecodEat.domain.products.dto.response.AnalysisResponseDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.time.Duration; + +@Component +@RequiredArgsConstructor +@Slf4j +public class PythonAnalysisClient { + + private final WebClient webClient; + + @Value("${python.server.url:http://3.37.218.215:8000/}") + private String pythonServerUrl; + + public Mono analyzeProduct(AnalysisRequestDto request) { + log.info("Sending analysis request to Python server: {}", pythonServerUrl); + + return webClient.post() + .uri(pythonServerUrl + "/api/v1/analyze") + .bodyValue(request) + .retrieve() + .bodyToMono(AnalysisResponseDto.class) + .timeout(Duration.ofMinutes(2)) + .doOnSuccess(response -> log.info("Analysis completed with status: {}", response.getDecodeStatus())) + .doOnError(error -> log.error("Analysis request failed: {}", error.getMessage())); + } +} \ No newline at end of file diff --git a/src/main/java/com/DecodEat/domain/products/dto/request/AnalysisRequestDto.java b/src/main/java/com/DecodEat/domain/products/dto/request/AnalysisRequestDto.java new file mode 100644 index 0000000..92dd84d --- /dev/null +++ b/src/main/java/com/DecodEat/domain/products/dto/request/AnalysisRequestDto.java @@ -0,0 +1,16 @@ +package com.DecodEat.domain.products.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class AnalysisRequestDto { + private List image_urls; +} \ No newline at end of file diff --git a/src/main/java/com/DecodEat/domain/products/dto/response/AnalysisResponseDto.java b/src/main/java/com/DecodEat/domain/products/dto/response/AnalysisResponseDto.java new file mode 100644 index 0000000..7880bba --- /dev/null +++ b/src/main/java/com/DecodEat/domain/products/dto/response/AnalysisResponseDto.java @@ -0,0 +1,21 @@ +package com.DecodEat.domain.products.dto.response; + +import com.DecodEat.domain.products.entity.DecodeStatus; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class AnalysisResponseDto { + private DecodeStatus decodeStatus; + private String product_name; + private NutritionInfoDto nutrition_info; + private List ingredients; + private String message; +} \ No newline at end of file diff --git a/src/main/java/com/DecodEat/domain/products/dto/response/NutritionInfoDto.java b/src/main/java/com/DecodEat/domain/products/dto/response/NutritionInfoDto.java new file mode 100644 index 0000000..446d483 --- /dev/null +++ b/src/main/java/com/DecodEat/domain/products/dto/response/NutritionInfoDto.java @@ -0,0 +1,24 @@ +package com.DecodEat.domain.products.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class NutritionInfoDto { + private String calcium; + private String carbohydrate; + private String cholesterol; + private String dietary_fiber; + private String energy; + private String fat; + private String protein; + private String sat_fat; + private String sodium; + private String sugar; + private String trans_fat; +} \ No newline at end of file diff --git a/src/main/java/com/DecodEat/domain/products/repository/ProductRawMaterialRepository.java b/src/main/java/com/DecodEat/domain/products/repository/ProductRawMaterialRepository.java new file mode 100644 index 0000000..cdf4e08 --- /dev/null +++ b/src/main/java/com/DecodEat/domain/products/repository/ProductRawMaterialRepository.java @@ -0,0 +1,14 @@ +package com.DecodEat.domain.products.repository; + +import com.DecodEat.domain.products.entity.Product; +import com.DecodEat.domain.products.entity.ProductRawMaterial; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface ProductRawMaterialRepository extends JpaRepository { + List findByProduct(Product product); + void deleteByProduct(Product product); +} \ No newline at end of file diff --git a/src/main/java/com/DecodEat/domain/products/repository/RawMaterialRepository.java b/src/main/java/com/DecodEat/domain/products/repository/RawMaterialRepository.java new file mode 100644 index 0000000..a9faae7 --- /dev/null +++ b/src/main/java/com/DecodEat/domain/products/repository/RawMaterialRepository.java @@ -0,0 +1,12 @@ +package com.DecodEat.domain.products.repository; + +import com.DecodEat.domain.products.entity.RawMaterial.RawMaterial; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface RawMaterialRepository extends JpaRepository { + Optional findByName(String name); +} \ No newline at end of file diff --git a/src/main/java/com/DecodEat/domain/products/service/ProductService.java b/src/main/java/com/DecodEat/domain/products/service/ProductService.java index e4b26e9..c4301c5 100644 --- a/src/main/java/com/DecodEat/domain/products/service/ProductService.java +++ b/src/main/java/com/DecodEat/domain/products/service/ProductService.java @@ -1,24 +1,32 @@ package com.DecodEat.domain.products.service; +import com.DecodEat.domain.products.client.PythonAnalysisClient; import com.DecodEat.domain.products.converter.ProductConverter; +import com.DecodEat.domain.products.dto.request.AnalysisRequestDto; import com.DecodEat.domain.products.dto.request.ProductRegisterRequestDto; import com.DecodEat.domain.products.dto.response.*; import com.DecodEat.domain.products.entity.DecodeStatus; import com.DecodEat.domain.products.entity.Product; import com.DecodEat.domain.products.entity.ProductInfoImage; import com.DecodEat.domain.products.entity.ProductNutrition; +import com.DecodEat.domain.products.entity.ProductRawMaterial; +import com.DecodEat.domain.products.entity.RawMaterial.RawMaterial; import com.DecodEat.domain.products.entity.RawMaterial.RawMaterialCategory; import com.DecodEat.domain.products.repository.ProductImageRepository; import com.DecodEat.domain.products.repository.ProductNutritionRepository; +import com.DecodEat.domain.products.repository.ProductRawMaterialRepository; import com.DecodEat.domain.products.repository.ProductRepository; +import com.DecodEat.domain.products.repository.RawMaterialRepository; import com.DecodEat.domain.products.repository.ProductSpecification; import com.DecodEat.domain.users.entity.User; import com.DecodEat.global.aws.s3.AmazonS3Manager; import com.DecodEat.global.dto.PageResponseDto; import com.DecodEat.global.exception.GeneralException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.*; import org.springframework.data.jpa.domain.Specification; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -35,11 +43,15 @@ @Service @RequiredArgsConstructor @Transactional +@Slf4j public class ProductService { private final ProductRepository productRepository; private final ProductImageRepository productImageRepository; private final ProductNutritionRepository productNutritionRepository; + private final RawMaterialRepository rawMaterialRepository; + private final ProductRawMaterialRepository productRawMaterialRepository; private final AmazonS3Manager amazonS3Manager; + private final PythonAnalysisClient pythonAnalysisClient; private static final int PAGE_SIZE = 12; @@ -91,6 +103,9 @@ public ProductRegisterResponseDto addProduct(User user, ProductRegisterRequestDt productInfoImageUrls = infoImages.stream().map(ProductInfoImage::getImageUrl).toList(); } + // 파이썬 서버에 비동기로 분석 요청 + requestAnalysisAsync(savedProduct.getProductId(), productInfoImageUrls); + return ProductConverter.toProductRegisterDto(savedProduct, productInfoImageUrls); } @@ -152,4 +167,154 @@ public PageResponseDto getRegisterHistory(User user, return new PageResponseDto<>(result); } + + @Async + public void requestAnalysisAsync(Long productId, List imageUrls) { + log.info("Starting async analysis for product ID: {}", productId); + + if (imageUrls == null || imageUrls.isEmpty()) { + log.warn("No images to analyze for product ID: {}", productId); + updateProductStatus(productId, DecodeStatus.FAILED, "No images provided for analysis"); + return; + } + + try { + AnalysisRequestDto request = AnalysisRequestDto.builder() + .image_urls(imageUrls) + .build(); + + pythonAnalysisClient.analyzeProduct(request) + .subscribe( + response -> processAnalysisResult(productId, response), + error -> { + log.error("Analysis failed for product ID: {}", productId, error); + updateProductStatus(productId, DecodeStatus.FAILED, "Analysis request failed: " + error.getMessage()); + } + ); + } catch (Exception e) { + log.error("Failed to send analysis request for product ID: {}", productId, e); + updateProductStatus(productId, DecodeStatus.FAILED, "Failed to send analysis request"); + } + } + + @Transactional + public void processAnalysisResult(Long productId, AnalysisResponseDto response) { + log.info("Processing analysis result for product ID: {} with status: {}", productId, response.getDecodeStatus()); + + try { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new GeneralException(PRODUCT_NOT_EXISTED)); + + // 상품 상태 업데이트 + product.setDecodeStatus(response.getDecodeStatus()); + productRepository.save(product); + + // 분석이 성공한 경우 영양정보 저장 + if (response.getDecodeStatus() == DecodeStatus.COMPLETED && response.getNutrition_info() != null) { + saveNutritionInfo(productId, response); + } + + log.info("Successfully processed analysis result for product ID: {}", productId); + } catch (Exception e) { + log.error("Failed to process analysis result for product ID: {}", productId, e); + updateProductStatus(productId, DecodeStatus.FAILED, "Failed to process analysis result"); + } + } + + @Transactional + public void updateProductStatus(Long productId, DecodeStatus status, String message) { + try { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new GeneralException(PRODUCT_NOT_EXISTED)); + + product.setDecodeStatus(status); + productRepository.save(product); + + log.info("Updated product ID: {} status to: {} - {}", productId, status, message); + } catch (Exception e) { + log.error("Failed to update product status for ID: {}", productId, e); + } + } + + private void saveNutritionInfo(Long productId, AnalysisResponseDto response) { + log.info("Saving nutrition info for product ID: {}", productId); + + try { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new GeneralException(PRODUCT_NOT_EXISTED)); + + // 영양정보 저장 + if (response.getNutrition_info() != null) { + ProductNutrition nutrition = ProductNutrition.builder() + .product(product) + .calcium(parseDouble(response.getNutrition_info().getCalcium())) + .carbohydrate(parseDouble(response.getNutrition_info().getCarbohydrate())) + .cholesterol(parseDouble(response.getNutrition_info().getCholesterol())) + .dietaryFiber(parseDouble(response.getNutrition_info().getDietary_fiber())) + .energy(parseDouble(response.getNutrition_info().getEnergy())) + .fat(parseDouble(response.getNutrition_info().getFat())) + .protein(parseDouble(response.getNutrition_info().getProtein())) + .satFat(parseDouble(response.getNutrition_info().getSat_fat())) + .sodium(parseDouble(response.getNutrition_info().getSodium())) + .sugar(parseDouble(response.getNutrition_info().getSugar())) + .transFat(parseDouble(response.getNutrition_info().getTrans_fat())) + .build(); + + productNutritionRepository.save(nutrition); + log.info("Saved nutrition info for product ID: {}", productId); + } + + // 원재료 정보 저장 + if (response.getIngredients() != null && !response.getIngredients().isEmpty()) { + saveIngredients(product, response.getIngredients()); + log.info("Saved {} ingredients for product ID: {}", response.getIngredients().size(), productId); + } + + } catch (Exception e) { + log.error("Failed to save nutrition info for product ID: {}", productId, e); + throw e; + } + } + + private void saveIngredients(Product product, List ingredientNames) { + // 기존 원재료 관계 삭제 + productRawMaterialRepository.deleteByProduct(product); + + for (String ingredientName : ingredientNames) { + if (ingredientName != null && !ingredientName.trim().isEmpty()) { + // 원재료가 이미 존재하는지 확인 + RawMaterial rawMaterial = rawMaterialRepository.findByName(ingredientName.trim()) + .orElseGet(() -> { + // 새로운 원재료 생성 (기본 카테고리는 OTHERS) + RawMaterial newRawMaterial = RawMaterial.builder() + .name(ingredientName.trim()) + .category(RawMaterialCategory.OTHERS) + .build(); + return rawMaterialRepository.save(newRawMaterial); + }); + + // 상품-원재료 관계 생성 + ProductRawMaterial productRawMaterial = ProductRawMaterial.builder() + .product(product) + .rawMaterial(rawMaterial) + .build(); + + productRawMaterialRepository.save(productRawMaterial); + } + } + } + + private Double parseDouble(String value) { + if (value == null || value.trim().isEmpty()) { + return null; + } + try { + // 숫자가 아닌 문자 제거 (단위 등) + String cleanValue = value.replaceAll("[^0-9.]", ""); + return cleanValue.isEmpty() ? null : Double.parseDouble(cleanValue); + } catch (NumberFormatException e) { + log.warn("Failed to parse double value: {}", value); + return null; + } + } } \ No newline at end of file diff --git a/src/main/java/com/DecodEat/global/config/AsyncConfig.java b/src/main/java/com/DecodEat/global/config/AsyncConfig.java new file mode 100644 index 0000000..4ceb55f --- /dev/null +++ b/src/main/java/com/DecodEat/global/config/AsyncConfig.java @@ -0,0 +1,24 @@ +package com.DecodEat.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + @Bean(name = "taskExecutor") + public Executor taskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(5); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("Analysis-"); + executor.initialize(); + return executor; + } +} \ No newline at end of file diff --git a/src/main/java/com/DecodEat/global/config/WebClientConfig.java b/src/main/java/com/DecodEat/global/config/WebClientConfig.java new file mode 100644 index 0000000..fa8199e --- /dev/null +++ b/src/main/java/com/DecodEat/global/config/WebClientConfig.java @@ -0,0 +1,16 @@ +package com.DecodEat.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +public class WebClientConfig { + + @Bean + public WebClient webClient() { + return WebClient.builder() + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(10 * 1024 * 1024)) // 10MB + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/DecodEat/global/config/jwt/JwtProperties.java b/src/main/java/com/DecodEat/global/config/jwt/JwtProperties.java index 56cbfc1..15d0aa1 100644 --- a/src/main/java/com/DecodEat/global/config/jwt/JwtProperties.java +++ b/src/main/java/com/DecodEat/global/config/jwt/JwtProperties.java @@ -3,14 +3,13 @@ import lombok.Getter; import lombok.Setter; import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Configuration; import org.springframework.stereotype.Component; -@Setter @Getter +@Setter @Component -@ConfigurationProperties(prefix = "spring.jwt") +@ConfigurationProperties(prefix = "jwt") public class JwtProperties { private String issuer; private String secretKey; -} +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index da195ac..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -#spring.application.name=DecodEat