Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AnalysisResponseDto> 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()));
}
}
Original file line number Diff line number Diff line change
@@ -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<String> image_urls;
}
Original file line number Diff line number Diff line change
@@ -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<String> ingredients;
private String message;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<ProductRawMaterial, Long> {
List<ProductRawMaterial> findByProduct(Product product);
void deleteByProduct(Product product);
}
Original file line number Diff line number Diff line change
@@ -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<RawMaterial, Long> {
Optional<RawMaterial> findByName(String name);
}
165 changes: 165 additions & 0 deletions src/main/java/com/DecodEat/domain/products/service/ProductService.java
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -152,4 +167,154 @@ public PageResponseDto<ProductRegisterHistoryDto> getRegisterHistory(User user,

return new PageResponseDto<>(result);
}

@Async
public void requestAnalysisAsync(Long productId, List<String> 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<String> 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;
}
}
}
24 changes: 24 additions & 0 deletions src/main/java/com/DecodEat/global/config/AsyncConfig.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading