diff --git a/src/main/java/or/sopt/houme/domain/furniture/infrastructure/dto/external/naverShop/FurnitureProductsInfoResponse.java b/src/main/java/or/sopt/houme/domain/furniture/infrastructure/dto/external/naverShop/FurnitureProductsInfoResponse.java index bcdc8ecb..54468d06 100644 --- a/src/main/java/or/sopt/houme/domain/furniture/infrastructure/dto/external/naverShop/FurnitureProductsInfoResponse.java +++ b/src/main/java/or/sopt/houme/domain/furniture/infrastructure/dto/external/naverShop/FurnitureProductsInfoResponse.java @@ -17,7 +17,14 @@ public record FurnitureProductInfo( String furnitureProductName, String furnitureProductMallName, Long furnitureProductId, - double similarity + double similarity, + List colors, + List clientColors, + Long listPrice, + Integer discountRate, + Long discountPrice, + String brandName, + Long jjymCount ) { public static FurnitureProductInfo of( Long id, @@ -35,7 +42,48 @@ public static FurnitureProductInfo of( furnitureProductName, furnitureProductMallName, furnitureProductId, - similarity + similarity, + List.of(), + List.of(), + null, + null, + null, + null, + 0L + ); + } + + public static FurnitureProductInfo of( + Long id, + String furnitureProductImageUrl, + String furnitureProductSiteUrl, + String furnitureProductName, + String furnitureProductMallName, + Long furnitureProductId, + double similarity, + List colors, + List clientColors, + Long listPrice, + Integer discountRate, + Long discountPrice, + String brandName, + Long jjymCount + ) { + return new FurnitureProductInfo( + id, + furnitureProductImageUrl, + furnitureProductSiteUrl, + furnitureProductName, + furnitureProductMallName, + furnitureProductId, + similarity, + colors, + clientColors, + listPrice, + discountRate, + discountPrice, + brandName, + jjymCount ); } } diff --git a/src/main/java/or/sopt/houme/domain/furniture/infrastructure/scheduler/CurationFurnitureScheduler.java b/src/main/java/or/sopt/houme/domain/furniture/infrastructure/scheduler/CurationFurnitureScheduler.java index 5b275f7f..2f355b55 100644 --- a/src/main/java/or/sopt/houme/domain/furniture/infrastructure/scheduler/CurationFurnitureScheduler.java +++ b/src/main/java/or/sopt/houme/domain/furniture/infrastructure/scheduler/CurationFurnitureScheduler.java @@ -4,9 +4,11 @@ import lombok.extern.slf4j.Slf4j; import or.sopt.houme.domain.furniture.infrastructure.dto.external.naverShop.FurnitureProductsInfoResponse; import or.sopt.houme.domain.furniture.infrastructure.dto.external.naverShop.NaverFurnitureProductDto; +import or.sopt.houme.domain.furniture.model.entity.CurationSource; import or.sopt.houme.domain.furniture.model.entity.FurnitureTag; import or.sopt.houme.domain.furniture.repository.FurnitureTagRepository; import or.sopt.houme.domain.furniture.service.CurationFurnitureService; +import or.sopt.houme.domain.furniture.service.CurationRawProductService; import or.sopt.houme.domain.furniture.service.ImageHashService; import or.sopt.houme.domain.furniture.service.NaverShopService; import or.sopt.houme.global.discord.DiscordWebhookService; @@ -32,6 +34,7 @@ public class CurationFurnitureScheduler { private final NaverShopService naverShopService; private final ImageHashService imageHashService; private final CurationFurnitureService curationFurnitureService; + private final CurationRawProductService curationRawProductService; private final DiscordWebhookService discordWebhookService; @Scheduled(cron = "0 0 2 * * *", zone = "Asia/Seoul") @@ -58,15 +61,27 @@ public void refreshCurationResults() { } FurnitureTag furnitureTag = furnitureTags.get(i); - List infos = fetchCurationWithRetry(furnitureTag); - if (infos.isEmpty()) { - emptyTags++; - log.warn("큐레이션 배치: 결과 없음 tagId={}", furnitureTag.getId()); + boolean hasResult = false; + + List naverInfos = fetchNaverCurationWithRetry(furnitureTag); + if (!naverInfos.isEmpty()) { + curationFurnitureService.saveCurationResults(furnitureTag, naverInfos, CurationSource.NAVER); + hasResult = true; + } + + List rawInfos = fetchRawCurationWithRetry(furnitureTag); + if (!rawInfos.isEmpty()) { + curationFurnitureService.saveCurationResults(furnitureTag, rawInfos, CurationSource.RAW); + hasResult = true; + } + + if (hasResult) { + successTags++; continue; } - curationFurnitureService.saveCurationResults(furnitureTag, infos); - successTags++; + emptyTags++; + log.warn("큐레이션 배치: NAVER/RAW 모두 결과 없음 tagId={}", furnitureTag.getId()); } } catch (Exception e) { status = "실패"; @@ -91,12 +106,12 @@ public void refreshCurationResults() { } } - private List fetchCurationWithRetry(FurnitureTag furnitureTag) { + private List fetchNaverCurationWithRetry(FurnitureTag furnitureTag) { for (int attempt = 1; attempt <= MAX_RETRY; attempt++) { try { - return fetchCurationInfos(furnitureTag); + return fetchNaverCurationInfos(furnitureTag); } catch (Exception e) { - log.warn("큐레이션 배치 실패: tagId={}, attempt={}", furnitureTag.getId(), attempt, e); + log.warn("네이버 큐레이션 배치 실패: tagId={}, attempt={}", furnitureTag.getId(), attempt, e); sleep(RETRY_BACKOFF_MILLIS * attempt); } } @@ -104,11 +119,33 @@ private List fetchCurationWi return List.of(); } - private List fetchCurationInfos(FurnitureTag furnitureTag) { + private List fetchRawCurationWithRetry(FurnitureTag furnitureTag) { + for (int attempt = 1; attempt <= MAX_RETRY; attempt++) { + try { + return fetchRawCurationInfos(furnitureTag); + } catch (Exception e) { + log.warn("RAW 큐레이션 배치 실패: tagId={}, attempt={}", furnitureTag.getId(), attempt, e); + sleep(RETRY_BACKOFF_MILLIS * attempt); + } + } + + return List.of(); + } + + private List fetchNaverCurationInfos(FurnitureTag furnitureTag) { List products = naverShopService.search(furnitureTag.getSearchKeyword(), NAVER_DISPLAY); return imageHashService.rankByImageSimilarity(furnitureTag.getFurnitureUrl(), products, TOP_N); } + private List fetchRawCurationInfos(FurnitureTag furnitureTag) { + List candidates = curationRawProductService.getCandidatesByFurnitureTag(furnitureTag); + if (candidates.isEmpty()) { + return List.of(); + } + + return imageHashService.rankByImageSimilarity(furnitureTag.getFurnitureUrl(), candidates, TOP_N); + } + private void sleep(long millis) { try { Thread.sleep(millis); diff --git a/src/main/java/or/sopt/houme/domain/furniture/model/entity/CurationFurniture.java b/src/main/java/or/sopt/houme/domain/furniture/model/entity/CurationFurniture.java index 56e54a59..926cf2a6 100644 --- a/src/main/java/or/sopt/houme/domain/furniture/model/entity/CurationFurniture.java +++ b/src/main/java/or/sopt/houme/domain/furniture/model/entity/CurationFurniture.java @@ -19,8 +19,8 @@ }, uniqueConstraints = { @UniqueConstraint( - name = "uk_curation_furniture_tag_rank", - columnNames = {"furniture_tag_id", "rank"} + name = "uk_curation_furniture_tag_source_rank", + columnNames = {"furniture_tag_id", "source", "rank"} ) } ) @@ -42,6 +42,10 @@ public class CurationFurniture { @Column(name = "rank", nullable = false) private Integer rank; + @Enumerated(EnumType.STRING) + @Column(name = "source", nullable = false) + private CurationSource source; + @Column(name = "similarity", nullable = false) private Double similarity; @@ -52,6 +56,7 @@ public static CurationFurniture of( FurnitureTag furnitureTag, RecommendFurniture recommendFurniture, int rank, + CurationSource source, double similarity, LocalDateTime fetchedAt ) { @@ -59,6 +64,7 @@ public static CurationFurniture of( .furnitureTag(furnitureTag) .recommendFurniture(recommendFurniture) .rank(rank) + .source(source) .similarity(similarity) .fetchedAt(fetchedAt) .build(); diff --git a/src/main/java/or/sopt/houme/domain/furniture/model/entity/CurationRawProduct.java b/src/main/java/or/sopt/houme/domain/furniture/model/entity/CurationRawProduct.java index dddacfac..e6d6df60 100644 --- a/src/main/java/or/sopt/houme/domain/furniture/model/entity/CurationRawProduct.java +++ b/src/main/java/or/sopt/houme/domain/furniture/model/entity/CurationRawProduct.java @@ -4,10 +4,13 @@ import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; @@ -29,7 +32,8 @@ indexes = { @Index(name = "idx_raw_source", columnList = "source"), @Index(name = "idx_raw_category", columnList = "category"), - @Index(name = "idx_raw_fetched_at", columnList = "fetched_at") + @Index(name = "idx_raw_fetched_at", columnList = "fetched_at"), + @Index(name = "idx_raw_furniture_tag_id", columnList = "furniture_tag_id") }, uniqueConstraints = { @UniqueConstraint( @@ -75,8 +79,12 @@ public class CurationRawProduct { @Comment("판매 몰 이름") private String productMallName; + @Column(name = "brand") + @Comment("브랜드") + private String brand; + @Column(name = "list_price") - @Comment("정가") + @Comment("임의 정가") private Long listPrice; @Column(name = "discount_rate") @@ -84,13 +92,26 @@ public class CurationRawProduct { private Integer discountRate; @Column(name = "discount_price") - @Comment("할인가") + @Comment("판매가") private Long discountPrice; + @Column(name = "base_shipping_fee") + @Comment("기본 배송비") + private Long baseShippingFee; + + @Column(name = "free_shipping_condition") + @Comment("무료 배송 조건") + private Long freeShippingCondition; + @Column(name = "fetched_at", nullable = false) @Comment("수집 시각") private LocalDateTime fetchedAt; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "furniture_tag_id") + @Comment("수동 매핑된 가구 태그") + private FurnitureTag furnitureTag; + public static CurationRawProduct of( String source, SoozipCategory category, @@ -136,4 +157,8 @@ public void updateFrom( this.fetchedAt = fetchedAt; } } + + public void updateFurnitureTag(FurnitureTag furnitureTag) { + this.furnitureTag = furnitureTag; + } } diff --git a/src/main/java/or/sopt/houme/domain/furniture/model/entity/CurationSource.java b/src/main/java/or/sopt/houme/domain/furniture/model/entity/CurationSource.java new file mode 100644 index 00000000..f5190d1f --- /dev/null +++ b/src/main/java/or/sopt/houme/domain/furniture/model/entity/CurationSource.java @@ -0,0 +1,6 @@ +package or.sopt.houme.domain.furniture.model.entity; + +public enum CurationSource { + NAVER, + RAW +} diff --git a/src/main/java/or/sopt/houme/domain/furniture/model/entity/RecommendFurniture.java b/src/main/java/or/sopt/houme/domain/furniture/model/entity/RecommendFurniture.java index 1e18bb1e..403ab295 100644 --- a/src/main/java/or/sopt/houme/domain/furniture/model/entity/RecommendFurniture.java +++ b/src/main/java/or/sopt/houme/domain/furniture/model/entity/RecommendFurniture.java @@ -12,12 +12,13 @@ @Table( name = "recommend_furnitures", indexes = { - @Index(name = "idx_furniture_product_id", columnList = "furniture_product_id") + @Index(name = "idx_furniture_product_id", columnList = "furniture_product_id"), + @Index(name = "idx_recommend_source", columnList = "source") }, uniqueConstraints = { @UniqueConstraint( - name = "uk_furniture_product_id", - columnNames = {"furniture_product_id"} + name = "uk_source_furniture_product_id", + columnNames = {"source", "furniture_product_id"} ) } ) @@ -47,6 +48,10 @@ public class RecommendFurniture { @Comment("추천 가구 식별자") private Long furnitureProductId; + @Enumerated(EnumType.STRING) + @Column(name = "source", nullable = false) + @Comment("큐레이션 소스") + private CurationSource source; public static RecommendFurniture from( @@ -54,7 +59,8 @@ public static RecommendFurniture from( String furnitureProductSiteUrl, String furnitureProductName, String furnitureProductMallName, - Long furnitureProductId + Long furnitureProductId, + CurationSource source ){ return RecommendFurniture.builder() .furnitureProductImageUrl(furnitureProductImageUrl) @@ -62,6 +68,7 @@ public static RecommendFurniture from( .furnitureProductName(furnitureProductName) .furnitureProductMallName(furnitureProductMallName) .furnitureProductId(furnitureProductId) + .source(source) .build(); } } diff --git a/src/main/java/or/sopt/houme/domain/furniture/repository/CurationFurnitureRepository.java b/src/main/java/or/sopt/houme/domain/furniture/repository/CurationFurnitureRepository.java index 72af583b..85925cde 100644 --- a/src/main/java/or/sopt/houme/domain/furniture/repository/CurationFurnitureRepository.java +++ b/src/main/java/or/sopt/houme/domain/furniture/repository/CurationFurnitureRepository.java @@ -1,6 +1,7 @@ package or.sopt.houme.domain.furniture.repository; import or.sopt.houme.domain.furniture.model.entity.CurationFurniture; +import or.sopt.houme.domain.furniture.model.entity.CurationSource; import or.sopt.houme.domain.furniture.model.entity.FurnitureTag; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; @@ -12,7 +13,7 @@ public interface CurationFurnitureRepository extends JpaRepository { @EntityGraph(attributePaths = "recommendFurniture") - List findAllByFurnitureTagOrderByRankAsc(FurnitureTag furnitureTag); + List findAllByFurnitureTagAndSourceOrderByRankAsc(FurnitureTag furnitureTag, CurationSource source); - void deleteByFurnitureTag(FurnitureTag furnitureTag); + void deleteByFurnitureTagAndSource(FurnitureTag furnitureTag, CurationSource source); } diff --git a/src/main/java/or/sopt/houme/domain/furniture/repository/CurationRawProductColorRepository.java b/src/main/java/or/sopt/houme/domain/furniture/repository/CurationRawProductColorRepository.java new file mode 100644 index 00000000..b6f4dce3 --- /dev/null +++ b/src/main/java/or/sopt/houme/domain/furniture/repository/CurationRawProductColorRepository.java @@ -0,0 +1,12 @@ +package or.sopt.houme.domain.furniture.repository; + +import or.sopt.houme.domain.furniture.model.entity.CurationRawProductColor; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CurationRawProductColorRepository extends JpaRepository { + List findAllByCurationRawProductIdIn(List curationRawProductIds); +} diff --git a/src/main/java/or/sopt/houme/domain/furniture/repository/CurationRawProductRepository.java b/src/main/java/or/sopt/houme/domain/furniture/repository/CurationRawProductRepository.java index cd93dad9..45aa9307 100644 --- a/src/main/java/or/sopt/houme/domain/furniture/repository/CurationRawProductRepository.java +++ b/src/main/java/or/sopt/houme/domain/furniture/repository/CurationRawProductRepository.java @@ -1,6 +1,7 @@ package or.sopt.houme.domain.furniture.repository; import or.sopt.houme.domain.furniture.model.entity.CurationRawProduct; +import or.sopt.houme.domain.furniture.model.entity.FurnitureTag; import or.sopt.houme.domain.furniture.model.entity.SoozipCategory; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -14,4 +15,8 @@ List findAllBySourceAndCategoryAndProductIdIn( SoozipCategory category, List productIds ); + + List findAllByFurnitureTag(FurnitureTag furnitureTag); + + List findAllByFurnitureTagAndProductIdIn(FurnitureTag furnitureTag, List productIds); } diff --git a/src/main/java/or/sopt/houme/domain/furniture/repository/JjymRepositoryCustom.java b/src/main/java/or/sopt/houme/domain/furniture/repository/JjymRepositoryCustom.java index 506fa5a5..55401c9d 100644 --- a/src/main/java/or/sopt/houme/domain/furniture/repository/JjymRepositoryCustom.java +++ b/src/main/java/or/sopt/houme/domain/furniture/repository/JjymRepositoryCustom.java @@ -3,10 +3,13 @@ import or.sopt.houme.domain.furniture.model.entity.Jjym; import java.util.List; +import java.util.Map; public interface JjymRepositoryCustom { List findAllByUserIdWithFurnitureOrderByCreatedAtDesc(Long userId); java.util.Optional findByUserIdAndRecommendFurnitureId(Long userId, Long recommendFurnitureId); + + Map countByRecommendFurnitureIds(List recommendFurnitureIds); } diff --git a/src/main/java/or/sopt/houme/domain/furniture/repository/JjymRepositoryImpl.java b/src/main/java/or/sopt/houme/domain/furniture/repository/JjymRepositoryImpl.java index 4cc3fa70..70432a89 100644 --- a/src/main/java/or/sopt/houme/domain/furniture/repository/JjymRepositoryImpl.java +++ b/src/main/java/or/sopt/houme/domain/furniture/repository/JjymRepositoryImpl.java @@ -7,7 +7,9 @@ import or.sopt.houme.domain.furniture.model.entity.QRecommendFurniture; import org.springframework.stereotype.Repository; +import java.util.HashMap; import java.util.List; +import java.util.Map; @Repository @RequiredArgsConstructor @@ -43,4 +45,30 @@ public java.util.Optional findByUserIdAndRecommendFurnitureId(Long userId, .fetchOne(); return java.util.Optional.ofNullable(result); } + + @Override + public Map countByRecommendFurnitureIds(List recommendFurnitureIds) { + if (recommendFurnitureIds == null || recommendFurnitureIds.isEmpty()) { + return Map.of(); + } + + QJjym jjym = QJjym.jjym; + com.querydsl.core.types.dsl.NumberExpression jjymCountExpr = jjym.count(); + List tuples = queryFactory + .select(jjym.recommendFurniture.id, jjymCountExpr) + .from(jjym) + .where(jjym.recommendFurniture.id.in(recommendFurnitureIds)) + .groupBy(jjym.recommendFurniture.id) + .fetch(); + + Map countMap = new HashMap<>(); + for (com.querydsl.core.Tuple tuple : tuples) { + Long recommendFurnitureId = tuple.get(jjym.recommendFurniture.id); + Long count = tuple.get(jjymCountExpr); + if (recommendFurnitureId != null && count != null) { + countMap.put(recommendFurnitureId, count); + } + } + return countMap; + } } diff --git a/src/main/java/or/sopt/houme/domain/furniture/repository/RecommendFurnitureRepository.java b/src/main/java/or/sopt/houme/domain/furniture/repository/RecommendFurnitureRepository.java index 9b227ad2..78ce5a44 100644 --- a/src/main/java/or/sopt/houme/domain/furniture/repository/RecommendFurnitureRepository.java +++ b/src/main/java/or/sopt/houme/domain/furniture/repository/RecommendFurnitureRepository.java @@ -1,11 +1,12 @@ package or.sopt.houme.domain.furniture.repository; import or.sopt.houme.domain.furniture.model.entity.RecommendFurniture; +import or.sopt.houme.domain.furniture.model.entity.CurationSource; import org.springframework.data.jpa.repository.JpaRepository; public interface RecommendFurnitureRepository extends JpaRepository { - boolean existsByFurnitureProductId(Long furnitureProductId); + boolean existsBySourceAndFurnitureProductId(CurationSource source, Long furnitureProductId); - java.util.Optional findByFurnitureProductId(Long furnitureProductId); + java.util.Optional findBySourceAndFurnitureProductId(CurationSource source, Long furnitureProductId); } diff --git a/src/main/java/or/sopt/houme/domain/furniture/service/CurationFurnitureService.java b/src/main/java/or/sopt/houme/domain/furniture/service/CurationFurnitureService.java index 2bf925df..53cc32af 100644 --- a/src/main/java/or/sopt/houme/domain/furniture/service/CurationFurnitureService.java +++ b/src/main/java/or/sopt/houme/domain/furniture/service/CurationFurnitureService.java @@ -1,16 +1,18 @@ package or.sopt.houme.domain.furniture.service; import or.sopt.houme.domain.furniture.infrastructure.dto.external.naverShop.FurnitureProductsInfoResponse; +import or.sopt.houme.domain.furniture.model.entity.CurationSource; import or.sopt.houme.domain.furniture.model.entity.FurnitureTag; import java.util.List; public interface CurationFurnitureService { - List getCurationProducts(FurnitureTag furnitureTag); + List getCurationProducts(FurnitureTag furnitureTag, CurationSource source); List saveCurationResults( FurnitureTag furnitureTag, - List infos + List infos, + CurationSource source ); } diff --git a/src/main/java/or/sopt/houme/domain/furniture/service/CurationFurnitureServiceImpl.java b/src/main/java/or/sopt/houme/domain/furniture/service/CurationFurnitureServiceImpl.java index 33098fc3..a435abe6 100644 --- a/src/main/java/or/sopt/houme/domain/furniture/service/CurationFurnitureServiceImpl.java +++ b/src/main/java/or/sopt/houme/domain/furniture/service/CurationFurnitureServiceImpl.java @@ -4,17 +4,27 @@ import lombok.extern.slf4j.Slf4j; import or.sopt.houme.domain.furniture.infrastructure.dto.external.naverShop.FurnitureProductsInfoResponse; import or.sopt.houme.domain.furniture.model.entity.CurationFurniture; +import or.sopt.houme.domain.furniture.model.entity.CurationRawProduct; +import or.sopt.houme.domain.furniture.model.entity.CurationRawProductColor; +import or.sopt.houme.domain.furniture.model.entity.CurationSource; import or.sopt.houme.domain.furniture.model.entity.FurnitureTag; import or.sopt.houme.domain.furniture.model.entity.RecommendFurniture; +import or.sopt.houme.domain.furniture.repository.CurationRawProductColorRepository; +import or.sopt.houme.domain.furniture.repository.CurationRawProductRepository; import or.sopt.houme.domain.furniture.repository.CurationFurnitureRepository; +import or.sopt.houme.domain.furniture.repository.JjymRepository; import or.sopt.houme.domain.furniture.repository.RecommendFurnitureRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -24,18 +34,31 @@ public class CurationFurnitureServiceImpl implements CurationFurnitureService { private final CurationFurnitureRepository curationFurnitureRepository; private final RecommendFurnitureRepository recommendFurnitureRepository; private final RecommendFurnitureService recommendFurnitureService; + private final CurationRawProductRepository curationRawProductRepository; + private final CurationRawProductColorRepository curationRawProductColorRepository; + private final JjymRepository jjymRepository; @Transactional(readOnly = true) @Override - public List getCurationProducts(FurnitureTag furnitureTag) { - List curations = curationFurnitureRepository.findAllByFurnitureTagOrderByRankAsc(furnitureTag); + public List getCurationProducts( + FurnitureTag furnitureTag, + CurationSource source + ) { + List curations = + curationFurnitureRepository.findAllByFurnitureTagAndSourceOrderByRankAsc(furnitureTag, source); if (curations.isEmpty()) { return List.of(); } + Map jjymCountByRecommendFurnitureId = buildJjymCountMap(curations); + Map rawMetaByProductId = buildRawMetaByProductId(furnitureTag, curations, source); + return curations.stream() .map(curation -> { RecommendFurniture recommendFurniture = curation.getRecommendFurniture(); + RawProductMeta rawMeta = rawMetaByProductId.get(recommendFurniture.getFurnitureProductId()); + List colors = rawMeta != null ? rawMeta.colors() : List.of(); + List clientColors = rawMeta != null ? rawMeta.clientColors() : List.of(); return FurnitureProductsInfoResponse.FurnitureProductInfo.of( recommendFurniture.getId(), @@ -44,7 +67,14 @@ public List getCurationProdu recommendFurniture.getFurnitureProductName(), recommendFurniture.getFurnitureProductMallName(), recommendFurniture.getFurnitureProductId(), - curation.getSimilarity() + curation.getSimilarity(), + colors, + clientColors, + rawMeta != null ? rawMeta.listPrice() : null, + rawMeta != null ? rawMeta.discountRate() : null, + rawMeta != null ? rawMeta.discountPrice() : null, + rawMeta != null ? rawMeta.brandName() : null, + jjymCountByRecommendFurnitureId.getOrDefault(recommendFurniture.getId(), 0L) ); }) .toList(); @@ -54,13 +84,14 @@ public List getCurationProdu @Override public List saveCurationResults( FurnitureTag furnitureTag, - List infos + List infos, + CurationSource source ) { if (infos == null || infos.isEmpty()) { return List.of(); } - Map idMapByProductId = recommendFurnitureService.saveRecommendFurniture(infos); + Map idMapByProductId = recommendFurnitureService.saveRecommendFurniture(infos, source); LocalDateTime fetchedAt = LocalDateTime.now(); List curations = new ArrayList<>(); @@ -77,6 +108,7 @@ public List saveCurationResu furnitureTag, recommendFurniture, rank, + source, info.similarity(), fetchedAt )); @@ -87,26 +119,126 @@ public List saveCurationResu return List.of(); } - curationFurnitureRepository.deleteByFurnitureTag(furnitureTag); + curationFurnitureRepository.deleteByFurnitureTagAndSource(furnitureTag, source); curationFurnitureRepository.saveAll(curations); - return mapResponseWithIds(infos, idMapByProductId); + return getCurationProducts(furnitureTag, source); } - private List mapResponseWithIds( - List infos, - Map idMapByProductId + private Map buildJjymCountMap(List curations) { + List recommendFurnitureIds = curations.stream() + .map(curation -> curation.getRecommendFurniture().getId()) + .distinct() + .toList(); + return jjymRepository.countByRecommendFurnitureIds(recommendFurnitureIds); + } + + private Map buildRawMetaByProductId( + FurnitureTag furnitureTag, + List curations, + CurationSource source ) { - return infos.stream() - .map(info -> FurnitureProductsInfoResponse.FurnitureProductInfo.of( - idMapByProductId.get(info.furnitureProductId()), - info.furnitureProductImageUrl(), - info.furnitureProductSiteUrl(), - info.furnitureProductName(), - info.furnitureProductMallName(), - info.furnitureProductId(), - info.similarity() - )) + if (source != CurationSource.RAW) { + return Map.of(); + } + + List productIds = curations.stream() + .map(curation -> curation.getRecommendFurniture().getFurnitureProductId()) + .filter(id -> id != null) + .distinct() + .toList(); + if (productIds.isEmpty()) { + return Map.of(); + } + + List rawProducts = + curationRawProductRepository.findAllByFurnitureTagAndProductIdIn(furnitureTag, productIds); + if (rawProducts.isEmpty()) { + return Map.of(); + } + + Map rawByProductId = rawProducts.stream() + .collect(Collectors.toMap( + CurationRawProduct::getProductId, + raw -> raw, + this::selectLatestRawProduct + )); + + List rawProductIds = rawProducts.stream() + .map(CurationRawProduct::getId) .toList(); + Map rawProductIdToProductId = rawProducts.stream() + .collect(Collectors.toMap( + CurationRawProduct::getId, + CurationRawProduct::getProductId, + (first, second) -> first + )); + + Map> rawColorsByProductId = new HashMap<>(); + Map> clientColorsByProductId = new HashMap<>(); + List colorEntities = + curationRawProductColorRepository.findAllByCurationRawProductIdIn(rawProductIds); + for (CurationRawProductColor colorEntity : colorEntities) { + Long rawProductId = colorEntity.getCurationRawProduct().getId(); + Long productId = rawProductIdToProductId.get(rawProductId); + if (productId == null) { + continue; + } + + String rawColorName = colorEntity.getRawColorName(); + String clientColorName = colorEntity.getClientColorName(); + + if (!isBlank(rawColorName)) { + rawColorsByProductId.computeIfAbsent(productId, key -> new LinkedHashSet<>()).add(rawColorName); + } + if (!isBlank(clientColorName)) { + clientColorsByProductId.computeIfAbsent(productId, key -> new LinkedHashSet<>()).add(clientColorName); + } + } + + Map rawMetaByProductId = new HashMap<>(); + for (Map.Entry entry : rawByProductId.entrySet()) { + Long productId = entry.getKey(); + CurationRawProduct rawProduct = entry.getValue(); + List colors = new ArrayList<>(rawColorsByProductId.getOrDefault(productId, Set.of())); + List clientColors = new ArrayList<>(clientColorsByProductId.getOrDefault(productId, Set.of())); + + rawMetaByProductId.put(productId, new RawProductMeta( + colors, + clientColors, + rawProduct.getListPrice(), + rawProduct.getDiscountRate(), + rawProduct.getDiscountPrice(), + rawProduct.getBrand() + )); + } + return rawMetaByProductId; + } + + private CurationRawProduct selectLatestRawProduct(CurationRawProduct current, CurationRawProduct candidate) { + LocalDateTime currentFetchedAt = current.getFetchedAt(); + LocalDateTime candidateFetchedAt = candidate.getFetchedAt(); + + if (currentFetchedAt == null) { + return candidate; + } + if (candidateFetchedAt == null) { + return current; + } + return candidateFetchedAt.isAfter(currentFetchedAt) ? candidate : current; + } + + private boolean isBlank(String value) { + return value == null || value.isBlank(); + } + + private record RawProductMeta( + List colors, + List clientColors, + Long listPrice, + Integer discountRate, + Long discountPrice, + String brandName + ) { } } diff --git a/src/main/java/or/sopt/houme/domain/furniture/service/CurationRawProductService.java b/src/main/java/or/sopt/houme/domain/furniture/service/CurationRawProductService.java index 83f4fc07..b3bb6ebb 100644 --- a/src/main/java/or/sopt/houme/domain/furniture/service/CurationRawProductService.java +++ b/src/main/java/or/sopt/houme/domain/furniture/service/CurationRawProductService.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import or.sopt.houme.domain.furniture.infrastructure.dto.external.naverShop.NaverFurnitureProductDto; import or.sopt.houme.domain.furniture.model.entity.CurationRawProduct; +import or.sopt.houme.domain.furniture.model.entity.FurnitureTag; import or.sopt.houme.domain.furniture.model.entity.SoozipCategory; import or.sopt.houme.domain.furniture.repository.CurationRawProductRepository; import or.sopt.houme.domain.furniture.service.dto.CurationRawProductSaveResult; @@ -24,6 +25,38 @@ public class CurationRawProductService { private final CurationRawProductRepository curationRawProductRepository; + @Transactional(readOnly = true) + public List getCandidatesByFurnitureTag(FurnitureTag furnitureTag) { + if (furnitureTag == null) { + return List.of(); + } + + List rawProducts = curationRawProductRepository.findAllByFurnitureTag(furnitureTag); + if (rawProducts.isEmpty()) { + return List.of(); + } + + return rawProducts.stream() + .filter(product -> product.getProductId() != null) + .filter(product -> !isBlank(product.getProductImageUrl())) + .filter(product -> !isBlank(product.getProductSiteUrl())) + .filter(product -> !isBlank(product.getProductName())) + .collect(Collectors.toMap( + CurationRawProduct::getProductId, + product -> new NaverFurnitureProductDto( + product.getProductImageUrl(), + product.getProductSiteUrl(), + product.getProductName(), + product.getProductMallName(), + product.getProductId() + ), + (first, second) -> first + )) + .values() + .stream() + .toList(); + } + @Transactional public CurationRawProductSaveResult saveAll( String source, diff --git a/src/main/java/or/sopt/houme/domain/furniture/service/RecommendFurnitureService.java b/src/main/java/or/sopt/houme/domain/furniture/service/RecommendFurnitureService.java index 0a40f34e..898bd909 100644 --- a/src/main/java/or/sopt/houme/domain/furniture/service/RecommendFurnitureService.java +++ b/src/main/java/or/sopt/houme/domain/furniture/service/RecommendFurnitureService.java @@ -1,10 +1,14 @@ package or.sopt.houme.domain.furniture.service; import or.sopt.houme.domain.furniture.infrastructure.dto.external.naverShop.FurnitureProductsInfoResponse; +import or.sopt.houme.domain.furniture.model.entity.CurationSource; import java.util.List; import java.util.Map; public interface RecommendFurnitureService { - Map saveRecommendFurniture(List requestDto); + Map saveRecommendFurniture( + List requestDto, + CurationSource source + ); } diff --git a/src/main/java/or/sopt/houme/domain/furniture/service/RecommendFurnitureServiceImpl.java b/src/main/java/or/sopt/houme/domain/furniture/service/RecommendFurnitureServiceImpl.java index 8478e443..d1290a59 100644 --- a/src/main/java/or/sopt/houme/domain/furniture/service/RecommendFurnitureServiceImpl.java +++ b/src/main/java/or/sopt/houme/domain/furniture/service/RecommendFurnitureServiceImpl.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import or.sopt.houme.domain.furniture.infrastructure.dto.external.naverShop.FurnitureProductsInfoResponse; +import or.sopt.houme.domain.furniture.model.entity.CurationSource; import or.sopt.houme.domain.furniture.model.entity.RecommendFurniture; import or.sopt.houme.domain.furniture.repository.RecommendFurnitureRepository; import org.springframework.stereotype.Service; @@ -20,8 +21,11 @@ public class RecommendFurnitureServiceImpl implements RecommendFurnitureService @Override - public Map saveRecommendFurniture(List requestDto) { - return saveSingleRecommendFurniture(requestDto); + public Map saveRecommendFurniture( + List requestDto, + CurationSource source + ) { + return saveSingleRecommendFurniture(requestDto, source); } @@ -30,7 +34,10 @@ public Map saveRecommendFurniture(List saveSingleRecommendFurniture(List requestDto) { + private Map saveSingleRecommendFurniture( + List requestDto, + CurationSource source + ) { Map idMapByProductId = new HashMap<>(); for (FurnitureProductsInfoResponse.FurnitureProductInfo furnitureProductInfo : requestDto) { @@ -38,9 +45,9 @@ private Map saveSingleRecommendFurniture(List saveSingleRecommendFurniture(List cachedInfos = - curationFurnitureService.getCurationProducts(furnitureTag); - if (!cachedInfos.isEmpty()) { - log.info("큐레이션 결과를 DB에서 조회합니다"); - return FurnitureProductsInfoResponse.of(user.getName(), cachedInfos); + List naverInfos = + curationFurnitureService.getCurationProducts(furnitureTag, CurationSource.NAVER); + if (naverInfos.isEmpty()) { + log.info("네이버 API 호출을 시작합니다"); + String keyword = furnitureTag.getSearchKeyword(); + List products = naverShopService.search(keyword, 50); + + // 2. FastAPI 호출 → 유사도 기반 상위 상품 리스트만 반환 + // 12/05 FAST API 삭제 + log.info("유사도 기반 네이버 상품 조회를 시작합니다"); + List rankedInfos = + imageHashService.rankByImageSimilarity(furnitureTag.getFurnitureUrl(), products, CURATION_LIMIT); + + // 2-1. 최종반환된 리스트를 기반으로 추천가구 엔티티 저장하고, 큐레이션 결과 저장 + log.info("네이버 큐레이션 결과 저장"); + naverInfos = curationFurnitureService.saveCurationResults( + furnitureTag, + rankedInfos, + CurationSource.NAVER + ); + } else { + log.info("네이버 큐레이션 결과를 DB에서 조회합니다"); } - // 2. DB에서 조회된 결과가 없는 경우 -> 네이버 API 호출 - log.info("네이버 API 호출을 시작합니다"); - String keyword = furnitureTag.getSearchKeyword(); - List products = naverShopService.search(keyword, 50); - // 3. FastAPI 호출 → 유사도 기반 상위 상품 리스트만 반환 - // 12/05 FAST API 삭제 - log.info("유사도 기반 상품 조회를 시작합니다"); - List infos = - imageHashService.rankByImageSimilarity(furnitureTag.getFurnitureUrl(), products, 5); + // 기본적인 로직은 naver 로직과 동일합니다 + // 1. furnitrure_tag에 맞는 데이터를 가져옵니다 + List rawInfos = + curationFurnitureService.getCurationProducts(furnitureTag, CurationSource.RAW); - // 3-1. 최종반환된 리스트를 기반으로 추천가구 엔티티 저장하고, 큐레이션 결과 저장 - log.info("최종반환된 리스트를 기반으로 큐레이션 결과 저장"); - List responseInfos = - curationFurnitureService.saveCurationResults(furnitureTag, infos); + // 2. 만약 존재하지 않는다면 그때부터 유사도 계산을 시작합니다 + if (rawInfos.isEmpty()) { + log.info("RAW 큐레이션 계산을 시작합니다"); + + // 2-1. 기존에는 네이버 API로부터 데이터를 불러받아 반환했다면, 이젠 curation_raw_product에서 가져옵니다 + List rawCandidates = + curationRawProductService.getCandidatesByFurnitureTag(furnitureTag); + + // 2-2. 그 후에 동일한 hash 기반 이미지 유사도를 판별하여 반환합니다 + if (!rawCandidates.isEmpty()) { + List rankedRawInfos = + imageHashService.rankByImageSimilarity(furnitureTag.getFurnitureUrl(), rawCandidates, CURATION_LIMIT); + rawInfos = curationFurnitureService.saveCurationResults( + furnitureTag, + rankedRawInfos, + CurationSource.RAW + ); + } else { + rawInfos = List.of(); + } + } else { + log.info("RAW 큐레이션 결과를 DB에서 조회합니다"); + } log.info("큐레이션 종료:{}",formatted); - return FurnitureProductsInfoResponse.of(user.getName(), responseInfos); + return FurnitureProductsInfoResponse.of( + user.getName(), + java.util.stream.Stream.concat(naverInfos.stream(), rawInfos.stream()).toList() + ); } // 기획의사결정용