diff --git a/src/main/java/com/wl2c/elswhereproductservice/client/analysis/api/AnalysisServiceClient.java b/src/main/java/com/wl2c/elswhereproductservice/client/analysis/api/AnalysisServiceClient.java new file mode 100644 index 0000000..c8ebb21 --- /dev/null +++ b/src/main/java/com/wl2c/elswhereproductservice/client/analysis/api/AnalysisServiceClient.java @@ -0,0 +1,18 @@ +package com.wl2c.elswhereproductservice.client.analysis.api; + +import com.wl2c.elswhereproductservice.client.analysis.dto.response.ResponseAIResultDto; +import com.wl2c.elswhereproductservice.domain.product.model.dto.request.RequestProductIdListDto; +import jakarta.validation.Valid; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +import java.util.List; + +@FeignClient(name = "analysis-service") +public interface AnalysisServiceClient { + + @PostMapping("/v1/ai/list") + List getAIResultList(@Valid @RequestBody RequestProductIdListDto requestProductIdListDto); + +} diff --git a/src/main/java/com/wl2c/elswhereproductservice/client/analysis/dto/response/ResponseAIResultDto.java b/src/main/java/com/wl2c/elswhereproductservice/client/analysis/dto/response/ResponseAIResultDto.java new file mode 100644 index 0000000..3d67426 --- /dev/null +++ b/src/main/java/com/wl2c/elswhereproductservice/client/analysis/dto/response/ResponseAIResultDto.java @@ -0,0 +1,22 @@ +package com.wl2c.elswhereproductservice.client.analysis.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import lombok.Getter; + +import java.math.BigDecimal; + +@Getter +@Builder +public class ResponseAIResultDto { + + @Schema(description = "AI 결과 id", example = "1") + private final Long AIResultId; + + @Schema(description = "상품 id", example = "1") + private final Long productId; + + @Schema(description = "AI가 판단한 스텝다운 상품 안전도", example = "0.76") + private final BigDecimal safetyScore; + +} diff --git a/src/main/java/com/wl2c/elswhereproductservice/domain/product/controller/ProductController.java b/src/main/java/com/wl2c/elswhereproductservice/domain/product/controller/ProductController.java index 2f0a3f3..96640d9 100644 --- a/src/main/java/com/wl2c/elswhereproductservice/domain/product/controller/ProductController.java +++ b/src/main/java/com/wl2c/elswhereproductservice/domain/product/controller/ProductController.java @@ -1,6 +1,7 @@ package com.wl2c.elswhereproductservice.domain.product.controller; import com.wl2c.elswhereproductservice.domain.product.exception.TodayReceivedProductsNotFoundException; +import com.wl2c.elswhereproductservice.domain.product.model.dto.list.SummarizedOnSaleProductDto; import com.wl2c.elswhereproductservice.domain.product.model.dto.list.SummarizedProductDto; import com.wl2c.elswhereproductservice.domain.product.model.dto.list.SummarizedProductForHoldingDto; import com.wl2c.elswhereproductservice.domain.product.model.dto.request.RequestProductIdListDto; @@ -50,14 +51,19 @@ public class ProductController { * 수익률순 : profit * 청약 마감일순 : deadline *

+ *

+ *
+ * 스텝다운 유형의 상품에 대해서 AI가 분석한 각 상품의 safetyScore를 제공합니다.
+ * 스텝다운 유형이 아니거나 스텝다운 유형이지만 분석 정보가 없는 경우에는 null 값으로 제공됩니다.
+ *

* * @param type 정렬 타입 * @return 페이징된 청약 중인 상품 목록 */ @GetMapping("/on-sale") - public ResponsePage listByOnSale(@RequestParam(name = "type") String type, - @ParameterObject Pageable pageable) { - Page result = productService.listByOnSale(type, pageable); + public ResponsePage listByOnSale(@RequestParam(name = "type") String type, + @ParameterObject Pageable pageable) { + Page result = productService.listByOnSale(type, pageable); return new ResponsePage<>(result); } diff --git a/src/main/java/com/wl2c/elswhereproductservice/domain/product/model/dto/list/SummarizedOnSaleProductDto.java b/src/main/java/com/wl2c/elswhereproductservice/domain/product/model/dto/list/SummarizedOnSaleProductDto.java new file mode 100644 index 0000000..172e229 --- /dev/null +++ b/src/main/java/com/wl2c/elswhereproductservice/domain/product/model/dto/list/SummarizedOnSaleProductDto.java @@ -0,0 +1,57 @@ +package com.wl2c.elswhereproductservice.domain.product.model.dto.list; + +import com.querydsl.core.annotations.QueryProjection; +import com.wl2c.elswhereproductservice.domain.product.model.ProductType; +import com.wl2c.elswhereproductservice.domain.product.model.entity.Product; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Getter +public class SummarizedOnSaleProductDto { + + @Schema(description = "상품 id", example = "1") + private final Long id; + + @Schema(description = "발행 회사", example = "oo투자증권") + private final String issuer; + + @Schema(description = "상품명", example = "oo투자증권 99999") + private final String name; + + @Schema(description = "상품 유형", example = "STEP_DOWN or LIZARD or MONTHLY_PAYMENT or ETC") + private final ProductType productType; + + @Schema(description = "기초자산", example = "KOSPI200 Index / HSCEI Index / S&P500 Index") + private final String equities; + + @Schema(description = "수익률", example = "20.55") + private final BigDecimal yieldIfConditionsMet; + + @Schema(description = "낙인 값", example = "45, 낙인 값이 없을 시 null return") + private final Integer knockIn; + + @Schema(description = "청약 시작일", example = "2024-06-14") + private final LocalDate subscriptionStartDate; + + @Schema(description = "청약 마감일", example = "2024-06-21") + private final LocalDate subscriptionEndDate; + + @Schema(description = "AI가 판단한 상품 안전도", example = "0.89") + private final BigDecimal safetyScore; + + public SummarizedOnSaleProductDto(Product product, BigDecimal safetyScore) { + this.id = product.getId(); + this.issuer = product.getIssuer(); + this.name = product.getName(); + this.productType = product.getType(); + this.equities = product.getEquities(); + this.yieldIfConditionsMet = product.getYieldIfConditionsMet(); + this.knockIn = product.getKnockIn(); + this.subscriptionStartDate = product.getSubscriptionStartDate(); + this.subscriptionEndDate = product.getSubscriptionEndDate(); + this.safetyScore = safetyScore; + } +} diff --git a/src/main/java/com/wl2c/elswhereproductservice/domain/product/model/dto/response/ResponseProductComparisonTargetDto.java b/src/main/java/com/wl2c/elswhereproductservice/domain/product/model/dto/response/ResponseProductComparisonTargetDto.java index fa9a514..7046ac0 100644 --- a/src/main/java/com/wl2c/elswhereproductservice/domain/product/model/dto/response/ResponseProductComparisonTargetDto.java +++ b/src/main/java/com/wl2c/elswhereproductservice/domain/product/model/dto/response/ResponseProductComparisonTargetDto.java @@ -46,7 +46,10 @@ public class ResponseProductComparisonTargetDto { @Schema(description = "청약 마감일", example = "2024-06-21") private final LocalDate subscriptionEndDate; - public ResponseProductComparisonTargetDto(Product product) { + @Schema(description = "AI가 판단한 상품 안전도", example = "0.89") + private final BigDecimal safetyScore; + + public ResponseProductComparisonTargetDto(Product product, BigDecimal safetyScore) { this.id = product.getId(); this.issuer = product.getIssuer(); this.name = product.getName(); @@ -59,5 +62,6 @@ public ResponseProductComparisonTargetDto(Product product) { this.maximumLossRate = product.getMaximumLossRate(); this.subscriptionStartDate = product.getSubscriptionStartDate(); this.subscriptionEndDate = product.getSubscriptionEndDate(); + this.safetyScore = safetyScore; } } diff --git a/src/main/java/com/wl2c/elswhereproductservice/domain/product/service/ProductService.java b/src/main/java/com/wl2c/elswhereproductservice/domain/product/service/ProductService.java index 0bf88ea..64b05c1 100644 --- a/src/main/java/com/wl2c/elswhereproductservice/domain/product/service/ProductService.java +++ b/src/main/java/com/wl2c/elswhereproductservice/domain/product/service/ProductService.java @@ -1,12 +1,17 @@ package com.wl2c.elswhereproductservice.domain.product.service; +import com.wl2c.elswhereproductservice.client.analysis.api.AnalysisServiceClient; +import com.wl2c.elswhereproductservice.client.analysis.dto.response.ResponseAIResultDto; import com.wl2c.elswhereproductservice.domain.like.service.LikeService; import com.wl2c.elswhereproductservice.domain.product.exception.NotOnSaleProductException; import com.wl2c.elswhereproductservice.domain.product.exception.ProductNotFoundException; import com.wl2c.elswhereproductservice.domain.product.exception.TodayReceivedProductsNotFoundException; import com.wl2c.elswhereproductservice.domain.product.exception.WrongProductSortTypeException; +import com.wl2c.elswhereproductservice.domain.product.model.ProductType; +import com.wl2c.elswhereproductservice.domain.product.model.dto.list.SummarizedOnSaleProductDto; import com.wl2c.elswhereproductservice.domain.product.model.dto.list.SummarizedProductDto; import com.wl2c.elswhereproductservice.domain.product.model.dto.list.SummarizedProductForHoldingDto; +import com.wl2c.elswhereproductservice.domain.product.model.dto.request.RequestProductIdListDto; import com.wl2c.elswhereproductservice.domain.product.model.dto.request.RequestProductSearchDto; import com.wl2c.elswhereproductservice.domain.product.model.dto.response.ResponseProductComparisonTargetDto; import com.wl2c.elswhereproductservice.domain.product.model.dto.response.ResponseSingleProductDto; @@ -18,9 +23,12 @@ import com.wl2c.elswhereproductservice.domain.product.repository.TickerSymbolRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cloud.client.circuitbreaker.CircuitBreaker; +import org.springframework.cloud.client.circuitbreaker.CircuitBreakerFactory; import org.springframework.data.domain.*; import org.springframework.stereotype.Service; +import java.math.BigDecimal; import java.util.*; import java.util.stream.Collectors; @@ -29,6 +37,9 @@ @Slf4j public class ProductService { + private final AnalysisServiceClient analysisServiceClient; + private final CircuitBreakerFactory circuitBreakerFactory; + private final ProductRepository productRepository; private final TickerSymbolRepository tickerSymbolRepository; private final ProductSearchRepository productSearchRepository; @@ -37,7 +48,7 @@ public class ProductService { private final LikeService likeService; private final DailyHotProductService dailyHotProductService; - public Page listByOnSale(String type, Pageable pageable) { + public Page listByOnSale(String type, Pageable pageable) { Sort sort = switch (type) { case "latest" -> Sort.by(Sort.Order.desc("subscriptionStartDate"), Sort.Order.desc("lastModifiedAt")); case "knock-in" -> Sort.by(Sort.Order.asc("knockIn").nullsLast(), Sort.Order.desc("lastModifiedAt")); @@ -53,7 +64,28 @@ public Page listByOnSale(String type, Pageable pageable) { Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort); Page products = productRepository.listByOnSale(type, sortedPageable); - return products.map(SummarizedProductDto::new); + List stepDownProductIds = products.getContent().stream() + .filter(product -> product.getType() == ProductType.STEP_DOWN) // STEP_DOWN 타입 필터링 + .map(Product::getId) + .toList(); + List responseStepDownAIResultDtos = listStepDownAIResult(stepDownProductIds); + + // AI 결과 리스트에서 productId를 key로 하는 Map으로 변환 + Map productSafetyScoreMap = responseStepDownAIResultDtos.stream() + .collect(Collectors.toMap( + ResponseAIResultDto::getProductId, + ResponseAIResultDto::getSafetyScore, + (existing, replacement) -> existing // 중복된 경우 기존 값 사용 + )); + + List summarizedProducts = products.getContent().stream() + .map(product -> { + BigDecimal safetyScore = productSafetyScoreMap.getOrDefault(product.getId(), null); + return new SummarizedOnSaleProductDto(product, safetyScore); + }) + .toList(); + + return new PageImpl<>(summarizedProducts, pageable, products.getTotalElements()); } public Page listByEndSale(String type, Pageable pageable) { @@ -118,17 +150,41 @@ public ResponseSingleProductDto findOne(Long productId, Long userId) { public Map> findComparisonTargets(Long id) { Map> result = new HashMap<>(); + // 타겟 상품 Product product = productRepository.isItProductOnSale(id).orElseThrow(NotOnSaleProductException::new); List tickerSymbolEntityList = tickerSymbolRepository.findTickerSymbolList(id); List tickerSymbolList = tickerSymbolEntityList.stream() .map(TickerSymbol::getTickerSymbol) .toList(); - List target = new ArrayList<>(); - target.add(new ResponseProductComparisonTargetDto(product)); + // 비교 상품 List productComparisonResults = productRepository.findComparisonResults(id, product.getEquityCount(), tickerSymbolList); + + // 타겟 및 비교 상품들 중 STEP_DOWN 타입 필터링 + List stepDownProductIds = new ArrayList<>(productComparisonResults.stream() + .filter(productComparisonResult -> productComparisonResult.getType() == ProductType.STEP_DOWN) + .map(Product::getId) + .toList()); + if (product.getType() == ProductType.STEP_DOWN) + stepDownProductIds.add(product.getId()); + List responseStepDownAIResultDtos = listStepDownAIResult(stepDownProductIds); + + // AI 결과 리스트에서 productId를 key로 하는 Map으로 변환 + Map productSafetyScoreMap = responseStepDownAIResultDtos.stream() + .collect(Collectors.toMap( + ResponseAIResultDto::getProductId, + ResponseAIResultDto::getSafetyScore, + (existing, replacement) -> existing // 중복된 경우 기존 값 사용 + )); + + List target = new ArrayList<>(); + target.add(new ResponseProductComparisonTargetDto(product, productSafetyScoreMap.getOrDefault(product.getId(), null))); + List comparisonResults = productComparisonResults.stream() - .map(ResponseProductComparisonTargetDto::new) + .map(productComparisonResult -> new ResponseProductComparisonTargetDto( + productComparisonResult, + productSafetyScoreMap.getOrDefault(productComparisonResult.getId(), null) + )) .toList(); result.put("target", target); @@ -187,4 +243,10 @@ public List getDailyTop5Products() { return summarizedProductDtos; } + + private List listStepDownAIResult(List stepDownProductIds) { + CircuitBreaker circuitBreaker = circuitBreakerFactory.create("aiResultCircuitBreaker"); + return circuitBreaker.run(() -> analysisServiceClient.getAIResultList(new RequestProductIdListDto(stepDownProductIds)), + throwable -> new ArrayList<>()); + } }