Skip to content

Commit 0d04d62

Browse files
authored
Merge pull request #36 from Decodeat/feat/python-server-connection
[Feat] 상품 등록시 파이썬 서버 분석 요청 및 응답결과 db저장
2 parents dd8dd55 + 4dc0fe8 commit 0d04d62

12 files changed

Lines changed: 334 additions & 5 deletions

File tree

build.gradle

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ dependencies {
5252
implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
5353
// S3 설정
5454
implementation 'com.amazonaws:aws-java-sdk-s3:1.12.649'
55+
56+
// HTTP 클라이언트 (파이썬 서버 통신용)
57+
implementation 'org.springframework.boot:spring-boot-starter-webflux'
5558
}
5659

5760
tasks.named('test') {
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package com.DecodEat.domain.products.client;
2+
3+
import com.DecodEat.domain.products.dto.request.AnalysisRequestDto;
4+
import com.DecodEat.domain.products.dto.response.AnalysisResponseDto;
5+
import lombok.RequiredArgsConstructor;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.beans.factory.annotation.Value;
8+
import org.springframework.stereotype.Component;
9+
import org.springframework.web.reactive.function.client.WebClient;
10+
import reactor.core.publisher.Mono;
11+
12+
import java.time.Duration;
13+
14+
@Component
15+
@RequiredArgsConstructor
16+
@Slf4j
17+
public class PythonAnalysisClient {
18+
19+
private final WebClient webClient;
20+
21+
@Value("${python.server.url:http://3.37.218.215:8000/}")
22+
private String pythonServerUrl;
23+
24+
public Mono<AnalysisResponseDto> analyzeProduct(AnalysisRequestDto request) {
25+
log.info("Sending analysis request to Python server: {}", pythonServerUrl);
26+
27+
return webClient.post()
28+
.uri(pythonServerUrl + "/api/v1/analyze")
29+
.bodyValue(request)
30+
.retrieve()
31+
.bodyToMono(AnalysisResponseDto.class)
32+
.timeout(Duration.ofMinutes(2))
33+
.doOnSuccess(response -> log.info("Analysis completed with status: {}", response.getDecodeStatus()))
34+
.doOnError(error -> log.error("Analysis request failed: {}", error.getMessage()));
35+
}
36+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package com.DecodEat.domain.products.dto.request;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Builder;
5+
import lombok.Getter;
6+
import lombok.NoArgsConstructor;
7+
8+
import java.util.List;
9+
10+
@Getter
11+
@Builder
12+
@AllArgsConstructor
13+
@NoArgsConstructor
14+
public class AnalysisRequestDto {
15+
private List<String> image_urls;
16+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.DecodEat.domain.products.dto.response;
2+
3+
import com.DecodEat.domain.products.entity.DecodeStatus;
4+
import lombok.AllArgsConstructor;
5+
import lombok.Builder;
6+
import lombok.Getter;
7+
import lombok.NoArgsConstructor;
8+
9+
import java.util.List;
10+
11+
@Getter
12+
@Builder
13+
@AllArgsConstructor
14+
@NoArgsConstructor
15+
public class AnalysisResponseDto {
16+
private DecodeStatus decodeStatus;
17+
private String product_name;
18+
private NutritionInfoDto nutrition_info;
19+
private List<String> ingredients;
20+
private String message;
21+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.DecodEat.domain.products.dto.response;
2+
3+
import lombok.AllArgsConstructor;
4+
import lombok.Builder;
5+
import lombok.Getter;
6+
import lombok.NoArgsConstructor;
7+
8+
@Getter
9+
@Builder
10+
@AllArgsConstructor
11+
@NoArgsConstructor
12+
public class NutritionInfoDto {
13+
private String calcium;
14+
private String carbohydrate;
15+
private String cholesterol;
16+
private String dietary_fiber;
17+
private String energy;
18+
private String fat;
19+
private String protein;
20+
private String sat_fat;
21+
private String sodium;
22+
private String sugar;
23+
private String trans_fat;
24+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.DecodEat.domain.products.repository;
2+
3+
import com.DecodEat.domain.products.entity.Product;
4+
import com.DecodEat.domain.products.entity.ProductRawMaterial;
5+
import org.springframework.data.jpa.repository.JpaRepository;
6+
import org.springframework.stereotype.Repository;
7+
8+
import java.util.List;
9+
10+
@Repository
11+
public interface ProductRawMaterialRepository extends JpaRepository<ProductRawMaterial, Long> {
12+
List<ProductRawMaterial> findByProduct(Product product);
13+
void deleteByProduct(Product product);
14+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package com.DecodEat.domain.products.repository;
2+
3+
import com.DecodEat.domain.products.entity.RawMaterial.RawMaterial;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
import org.springframework.stereotype.Repository;
6+
7+
import java.util.Optional;
8+
9+
@Repository
10+
public interface RawMaterialRepository extends JpaRepository<RawMaterial, Long> {
11+
Optional<RawMaterial> findByName(String name);
12+
}

src/main/java/com/DecodEat/domain/products/service/ProductService.java

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,32 @@
11
package com.DecodEat.domain.products.service;
22

3+
import com.DecodEat.domain.products.client.PythonAnalysisClient;
34
import com.DecodEat.domain.products.converter.ProductConverter;
5+
import com.DecodEat.domain.products.dto.request.AnalysisRequestDto;
46
import com.DecodEat.domain.products.dto.request.ProductRegisterRequestDto;
57
import com.DecodEat.domain.products.dto.response.*;
68
import com.DecodEat.domain.products.entity.DecodeStatus;
79
import com.DecodEat.domain.products.entity.Product;
810
import com.DecodEat.domain.products.entity.ProductInfoImage;
911
import com.DecodEat.domain.products.entity.ProductNutrition;
12+
import com.DecodEat.domain.products.entity.ProductRawMaterial;
13+
import com.DecodEat.domain.products.entity.RawMaterial.RawMaterial;
1014
import com.DecodEat.domain.products.entity.RawMaterial.RawMaterialCategory;
1115
import com.DecodEat.domain.products.repository.ProductImageRepository;
1216
import com.DecodEat.domain.products.repository.ProductNutritionRepository;
17+
import com.DecodEat.domain.products.repository.ProductRawMaterialRepository;
1318
import com.DecodEat.domain.products.repository.ProductRepository;
19+
import com.DecodEat.domain.products.repository.RawMaterialRepository;
1420
import com.DecodEat.domain.products.repository.ProductSpecification;
1521
import com.DecodEat.domain.users.entity.User;
1622
import com.DecodEat.global.aws.s3.AmazonS3Manager;
1723
import com.DecodEat.global.dto.PageResponseDto;
1824
import com.DecodEat.global.exception.GeneralException;
1925
import lombok.RequiredArgsConstructor;
26+
import lombok.extern.slf4j.Slf4j;
2027
import org.springframework.data.domain.*;
2128
import org.springframework.data.jpa.domain.Specification;
29+
import org.springframework.scheduling.annotation.Async;
2230
import org.springframework.stereotype.Service;
2331
import org.springframework.transaction.annotation.Transactional;
2432
import org.springframework.util.StringUtils;
@@ -35,11 +43,15 @@
3543
@Service
3644
@RequiredArgsConstructor
3745
@Transactional
46+
@Slf4j
3847
public class ProductService {
3948
private final ProductRepository productRepository;
4049
private final ProductImageRepository productImageRepository;
4150
private final ProductNutritionRepository productNutritionRepository;
51+
private final RawMaterialRepository rawMaterialRepository;
52+
private final ProductRawMaterialRepository productRawMaterialRepository;
4253
private final AmazonS3Manager amazonS3Manager;
54+
private final PythonAnalysisClient pythonAnalysisClient;
4355

4456

4557
private static final int PAGE_SIZE = 12;
@@ -91,6 +103,9 @@ public ProductRegisterResponseDto addProduct(User user, ProductRegisterRequestDt
91103
productInfoImageUrls = infoImages.stream().map(ProductInfoImage::getImageUrl).toList();
92104
}
93105

106+
// 파이썬 서버에 비동기로 분석 요청
107+
requestAnalysisAsync(savedProduct.getProductId(), productInfoImageUrls);
108+
94109
return ProductConverter.toProductRegisterDto(savedProduct, productInfoImageUrls);
95110
}
96111

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

153168
return new PageResponseDto<>(result);
154169
}
170+
171+
@Async
172+
public void requestAnalysisAsync(Long productId, List<String> imageUrls) {
173+
log.info("Starting async analysis for product ID: {}", productId);
174+
175+
if (imageUrls == null || imageUrls.isEmpty()) {
176+
log.warn("No images to analyze for product ID: {}", productId);
177+
updateProductStatus(productId, DecodeStatus.FAILED, "No images provided for analysis");
178+
return;
179+
}
180+
181+
try {
182+
AnalysisRequestDto request = AnalysisRequestDto.builder()
183+
.image_urls(imageUrls)
184+
.build();
185+
186+
pythonAnalysisClient.analyzeProduct(request)
187+
.subscribe(
188+
response -> processAnalysisResult(productId, response),
189+
error -> {
190+
log.error("Analysis failed for product ID: {}", productId, error);
191+
updateProductStatus(productId, DecodeStatus.FAILED, "Analysis request failed: " + error.getMessage());
192+
}
193+
);
194+
} catch (Exception e) {
195+
log.error("Failed to send analysis request for product ID: {}", productId, e);
196+
updateProductStatus(productId, DecodeStatus.FAILED, "Failed to send analysis request");
197+
}
198+
}
199+
200+
@Transactional
201+
public void processAnalysisResult(Long productId, AnalysisResponseDto response) {
202+
log.info("Processing analysis result for product ID: {} with status: {}", productId, response.getDecodeStatus());
203+
204+
try {
205+
Product product = productRepository.findById(productId)
206+
.orElseThrow(() -> new GeneralException(PRODUCT_NOT_EXISTED));
207+
208+
// 상품 상태 업데이트
209+
product.setDecodeStatus(response.getDecodeStatus());
210+
productRepository.save(product);
211+
212+
// 분석이 성공한 경우 영양정보 저장
213+
if (response.getDecodeStatus() == DecodeStatus.COMPLETED && response.getNutrition_info() != null) {
214+
saveNutritionInfo(productId, response);
215+
}
216+
217+
log.info("Successfully processed analysis result for product ID: {}", productId);
218+
} catch (Exception e) {
219+
log.error("Failed to process analysis result for product ID: {}", productId, e);
220+
updateProductStatus(productId, DecodeStatus.FAILED, "Failed to process analysis result");
221+
}
222+
}
223+
224+
@Transactional
225+
public void updateProductStatus(Long productId, DecodeStatus status, String message) {
226+
try {
227+
Product product = productRepository.findById(productId)
228+
.orElseThrow(() -> new GeneralException(PRODUCT_NOT_EXISTED));
229+
230+
product.setDecodeStatus(status);
231+
productRepository.save(product);
232+
233+
log.info("Updated product ID: {} status to: {} - {}", productId, status, message);
234+
} catch (Exception e) {
235+
log.error("Failed to update product status for ID: {}", productId, e);
236+
}
237+
}
238+
239+
private void saveNutritionInfo(Long productId, AnalysisResponseDto response) {
240+
log.info("Saving nutrition info for product ID: {}", productId);
241+
242+
try {
243+
Product product = productRepository.findById(productId)
244+
.orElseThrow(() -> new GeneralException(PRODUCT_NOT_EXISTED));
245+
246+
// 영양정보 저장
247+
if (response.getNutrition_info() != null) {
248+
ProductNutrition nutrition = ProductNutrition.builder()
249+
.product(product)
250+
.calcium(parseDouble(response.getNutrition_info().getCalcium()))
251+
.carbohydrate(parseDouble(response.getNutrition_info().getCarbohydrate()))
252+
.cholesterol(parseDouble(response.getNutrition_info().getCholesterol()))
253+
.dietaryFiber(parseDouble(response.getNutrition_info().getDietary_fiber()))
254+
.energy(parseDouble(response.getNutrition_info().getEnergy()))
255+
.fat(parseDouble(response.getNutrition_info().getFat()))
256+
.protein(parseDouble(response.getNutrition_info().getProtein()))
257+
.satFat(parseDouble(response.getNutrition_info().getSat_fat()))
258+
.sodium(parseDouble(response.getNutrition_info().getSodium()))
259+
.sugar(parseDouble(response.getNutrition_info().getSugar()))
260+
.transFat(parseDouble(response.getNutrition_info().getTrans_fat()))
261+
.build();
262+
263+
productNutritionRepository.save(nutrition);
264+
log.info("Saved nutrition info for product ID: {}", productId);
265+
}
266+
267+
// 원재료 정보 저장
268+
if (response.getIngredients() != null && !response.getIngredients().isEmpty()) {
269+
saveIngredients(product, response.getIngredients());
270+
log.info("Saved {} ingredients for product ID: {}", response.getIngredients().size(), productId);
271+
}
272+
273+
} catch (Exception e) {
274+
log.error("Failed to save nutrition info for product ID: {}", productId, e);
275+
throw e;
276+
}
277+
}
278+
279+
private void saveIngredients(Product product, List<String> ingredientNames) {
280+
// 기존 원재료 관계 삭제
281+
productRawMaterialRepository.deleteByProduct(product);
282+
283+
for (String ingredientName : ingredientNames) {
284+
if (ingredientName != null && !ingredientName.trim().isEmpty()) {
285+
// 원재료가 이미 존재하는지 확인
286+
RawMaterial rawMaterial = rawMaterialRepository.findByName(ingredientName.trim())
287+
.orElseGet(() -> {
288+
// 새로운 원재료 생성 (기본 카테고리는 OTHERS)
289+
RawMaterial newRawMaterial = RawMaterial.builder()
290+
.name(ingredientName.trim())
291+
.category(RawMaterialCategory.OTHERS)
292+
.build();
293+
return rawMaterialRepository.save(newRawMaterial);
294+
});
295+
296+
// 상품-원재료 관계 생성
297+
ProductRawMaterial productRawMaterial = ProductRawMaterial.builder()
298+
.product(product)
299+
.rawMaterial(rawMaterial)
300+
.build();
301+
302+
productRawMaterialRepository.save(productRawMaterial);
303+
}
304+
}
305+
}
306+
307+
private Double parseDouble(String value) {
308+
if (value == null || value.trim().isEmpty()) {
309+
return null;
310+
}
311+
try {
312+
// 숫자가 아닌 문자 제거 (단위 등)
313+
String cleanValue = value.replaceAll("[^0-9.]", "");
314+
return cleanValue.isEmpty() ? null : Double.parseDouble(cleanValue);
315+
} catch (NumberFormatException e) {
316+
log.warn("Failed to parse double value: {}", value);
317+
return null;
318+
}
319+
}
155320
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.DecodEat.global.config;
2+
3+
import org.springframework.context.annotation.Bean;
4+
import org.springframework.context.annotation.Configuration;
5+
import org.springframework.scheduling.annotation.EnableAsync;
6+
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
7+
8+
import java.util.concurrent.Executor;
9+
10+
@Configuration
11+
@EnableAsync
12+
public class AsyncConfig {
13+
14+
@Bean(name = "taskExecutor")
15+
public Executor taskExecutor() {
16+
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
17+
executor.setCorePoolSize(2);
18+
executor.setMaxPoolSize(5);
19+
executor.setQueueCapacity(100);
20+
executor.setThreadNamePrefix("Analysis-");
21+
executor.initialize();
22+
return executor;
23+
}
24+
}

0 commit comments

Comments
 (0)